From af804d48b66d143d61ce1f899acabb0a8b4acd48 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Fri, 19 Sep 2025 16:46:18 +0200 Subject: [PATCH] refactor: externalize learn modules and reduce reactivity --- shared/data/learnModules.ts | 928 +++++++++++++++++++++++++++++++----- shared/learn/scenario.ts | 377 +++++++++++++++ shared/learn/types.ts | 141 ++++++ 3 files changed, 1319 insertions(+), 127 deletions(-) create mode 100644 shared/learn/scenario.ts create mode 100644 shared/learn/types.ts diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index 1d75745..3470928 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -1,130 +1,804 @@ -import {ref} from "vue"; +import { createBaseScenario, formatTemp } from '~~/shared/learn/scenario' +import type { ModuleDef } from '~~/shared/learn/types' -/** MODULES **/ -export type Lesson = { id: string; title: string; desc: string; target: string; hints: string[]; keywords: string[] } -export type ModuleDef = { id: string; title: string; subtitle: string; art: string; lessons: Lesson[] } +function gradientArt(colors: string[]): string { + const stops = colors + .map((color, idx) => ``) + .join('') + const svg = `${stops}` + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` +} -const modules = ref([ - // New: ICAO chapter - { - id: 'icao', - title: 'ICAO Alphabet', - subtitle: 'Alphabets & Numbers', - art: 'https://images.unsplash.com/photo-1488085061387-422e29b40080?q=80&w=1600&auto=format&fit=crop', - lessons: [ - { - id: 'alpha', - title: 'Alphabet A–M', - desc: 'Say Alpha through Mike.', - target: 'Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo Lima Mike.', - hints: ['Keep a steady pace', 'Separate each word clearly'], - keywords: ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliett', 'Kilo', 'Lima', 'Mike'] - }, - { - id: 'alpha2', - title: 'Alphabet N–Z', - desc: 'November through Zulu.', - target: 'November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu.', - hints: ['Spell X-ray with a hyphen', "Juliett has two t's"], - keywords: ['November', 'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform', 'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu'] - }, - { - id: 'numbers', - title: 'Numbers', - desc: 'Read ICAO numbers.', - target: 'Tree Fower Fife Six Seven Eight Niner Zero.', - hints: ['Nine → Niner', 'Three → Tree', 'Four → Fower', 'Five → Fife'], - keywords: ['Tree', 'Fower', 'Fife', 'Niner'] - }, - { - id: 'callsign-icao', - title: 'Spell the call sign', - desc: 'Sample call sign.', - target: 'DLH one two three, Lufthansa one two three.', - hints: ['DLH → Lufthansa', 'Use ICAO numbers'], - keywords: ['Lufthansa', 'DLH', 'one', 'two', 'three'] - } - ] - }, - { - id: 'basics', - title: 'Basics', - subtitle: 'Call sign · Structure · Numbers', - art: 'https://images.unsplash.com/photo-1541392822270-85b2ff6c4577?q=80&w=1600&auto=format&fit=crop', - lessons: [ - { - id: 'checkin', - title: 'Check-in', - desc: 'Make the first call correctly.', - target: 'Frankfurt Ground, Lufthansa one two three at stand A12, request taxi.', - hints: ['Station • Callsign • Position • Intent'], - keywords: ['Frankfurt Ground', 'Lufthansa', 'stand', 'request taxi'] - }, - { - id: 'readback', title: 'Short Readback', desc: 'Give a short acknowledgement.', - target: 'Lufthansa one two three, roger.', - hints: ['Callsign + roger/affirm'], keywords: ['roger', 'affirm'] - } - ] - }, - { - id: 'ground', - title: 'Ground', - subtitle: 'Taxi • Hold Short • Handoff', - art: 'https://images.unsplash.com/photo-1523961131990-5ea7c61b2107?q=80&w=1600&auto=format&fit=crop', - lessons: [ - { - id: 'taxi1', title: 'Taxi-Clearance', desc: 'Via A, A5, B2.', - target: 'Lufthansa one two three, taxi to runway two five via A, A five, B two, hold short runway two five.', - hints: ['Taxi to runway • via • hold short'], keywords: ['taxi to runway', 'via', 'hold short'] - }, - { - id: 'handoff', title: 'Handoff', desc: 'Frequency change.', - target: 'Contact Tower on one one niner decimal five, Lufthansa one two three.', - hints: ['Contact Tower on … • decimal'], keywords: ['Contact Tower', 'decimal'] - } - ] - }, - { - id: 'departure', - title: 'Departure', - subtitle: 'Line up • Takeoff', - art: 'https://images.unsplash.com/photo-1494412685616-a5d310fbb07d?q=80&w=1600&auto=format&fit=crop', - lessons: [ - { - id: 'lineup', title: 'Line up', desc: 'Line up and wait.', - target: 'Lufthansa one two three, line up and wait runway two five.', - hints: ['line up and wait'], keywords: ['line up and wait'] - } - ] - }, - { - id: 'arrival', - title: 'Arrival', - subtitle: 'Approach • Vacate', - art: 'https://images.unsplash.com/photo-1542089363-07b2d92aacc3?q=80&w=1600&auto=format&fit=crop', - lessons: [ - { - id: 'vacate', title: 'Vacate', desc: 'Exit the runway and report clear.', - target: 'Lufthansa one two three, vacated runway two five via A six.', - hints: ['vacated runway • via taxiway'], keywords: ['vacated', 'runway'] - } - ] - }, - { - id: 'vatsim', - title: 'VATSIM', - subtitle: 'Netiquette • Connect', - art: 'https://images.unsplash.com/photo-1508264769638-658b34d79f6e?q=80&w=1600&auto=format&fit=crop', - lessons: [ - { - id: 'checkin', title: 'IFR Check-in', desc: 'First online call.', - target: 'Frankfurt Ground, Lufthansa one two three, A320 at stand A12, IFR to Munich, information Bravo, request clearance.', - hints: ['IFR/VFR • ATIS Info • Request'], keywords: ['IFR', 'information', 'request clearance'] - } - ] - } -]) - -export default modules +export const learnModules: ModuleDef[] = [ + { + id: 'normalize', + title: 'Normalize · Fundamentals', + subtitle: 'Alphabet, ATIS, METAR & Radio Check', + art: 'https://images.unsplash.com/photo-1488085061387-422e29b40080?q=80&w=1600&auto=format&fit=crop', + lessons: [ + { + id: 'icao-alphabet', + title: 'ICAO Alphabet & Numbers', + desc: 'Spell letters and digits clearly', + keywords: ['Alphabet', 'Numbers', 'Normalize'], + hints: [ + 'Spell each letter with its ICAO name (e.g. Delta, Lima, Hotel).', + 'Use ATC numbers: tree, fower, fife, niner.' + ], + fields: [ + { + key: 'letters', + label: 'Letters', + expected: scenario => scenario.callsignNato, + placeholder: 'Delta Lima Hotel', + width: 'xl', + threshold: 0.9 + }, + { + key: 'digits', + label: 'Numbers', + expected: scenario => scenario.flightNumberWords, + placeholder: 'one two three', + width: 'lg', + threshold: 0.88 + } + ], + readback: [ + { type: 'text', text: 'Call sign: ' }, + { type: 'field', key: 'letters', width: 'lg' }, + { type: 'text', text: ' · ' }, + { type: 'field', key: 'digits', width: 'md' } + ], + defaultFrequency: 'DEL', + phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`, + info: scenario => [ + `Callsign: ${scenario.callsign}`, + `Radio call: ${scenario.radioCall}`, + `ICAO spelling: ${scenario.callsignNato}`, + `Flight number spoken: ${scenario.flightNumberWords}` + ], + generate: createBaseScenario + }, + { + id: 'atis', + title: 'Understand the ATIS', + desc: 'Extract the identifier and key data from the ATIS', + keywords: ['ATIS', 'Weather'], + hints: [ + 'Remember the ATIS identifier as a single letter.', + 'Order: runway – wind – visibility – temperature – dew point – QNH.' + ], + fields: [ + { + key: 'atis-code', + label: 'ATIS', + expected: scenario => scenario.atisCode, + alternatives: scenario => [ + scenario.atisCode, + scenario.atisCode.toLowerCase(), + `Information ${scenario.atisCode}` + ], + placeholder: 'Letter', + width: 'xs', + threshold: 0.9 + }, + { + key: 'atis-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'atis-wind', + label: 'Wind', + expected: scenario => scenario.wind, + alternatives: scenario => [ + scenario.wind, + `${scenario.wind}KT`, + scenario.windWords + ], + width: 'md' + }, + { + key: 'atis-visibility', + label: 'Visibility', + expected: scenario => scenario.atisSummary.visibility, + alternatives: scenario => [scenario.visibility, scenario.visibilityWords], + width: 'sm' + }, + { + key: 'atis-temp', + label: 'Temperature', + expected: scenario => scenario.atisSummary.temperature, + alternatives: scenario => [scenario.temperatureWords], + width: 'sm' + }, + { + key: 'atis-dew', + label: 'Dew point', + expected: scenario => scenario.atisSummary.dewpoint, + alternatives: scenario => [scenario.dewpointWords], + width: 'sm' + }, + { + key: 'atis-qnh', + label: 'QNH', + expected: scenario => scenario.qnh.toString(), + alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords], + width: 'sm' + } + ], + readback: [ + { type: 'text', text: 'Information ' }, + { type: 'field', key: 'atis-code', width: 'xs' }, + { type: 'text', text: ', runway ' }, + { type: 'field', key: 'atis-runway', width: 'sm' }, + { type: 'text', text: ', wind ' }, + { type: 'field', key: 'atis-wind', width: 'md' }, + { type: 'text', text: ', visibility ' }, + { type: 'field', key: 'atis-visibility', width: 'sm' }, + { type: 'text', text: ', temperature ' }, + { type: 'field', key: 'atis-temp', width: 'sm' }, + { type: 'text', text: ', dew point ' }, + { type: 'field', key: 'atis-dew', width: 'sm' }, + { type: 'text', text: ', QNH ' }, + { type: 'field', key: 'atis-qnh', width: 'sm' } + ], + defaultFrequency: 'ATIS', + phrase: scenario => scenario.atisText, + info: () => [ + 'Note the identifier, runway, wind, visibility, temperature, dew point, and QNH.', + 'Visibility: four digits or 9999 for ≥10 km; QNH as a four-digit value.' + ], + generate: createBaseScenario + }, + { + id: 'metar', + title: 'Decode the METAR', + desc: 'Extract the raw METAR values', + keywords: ['METAR', 'Weather'], + hints: [ + 'Read the METAR in blocks: wind – visibility – clouds – temperature – QNH.', + 'The temperature block looks like 18/10; negative values start with M.' + ], + fields: [ + { + key: 'metar-wind', + label: 'Wind', + expected: scenario => scenario.metarSegments.wind, + alternatives: scenario => [ + scenario.metarSegments.wind, + `${scenario.wind.replace('/', '')}KT`, + scenario.wind + ], + width: 'md' + }, + { + key: 'metar-vis', + label: 'Visibility', + expected: scenario => scenario.metarSegments.visibility, + alternatives: scenario => [scenario.visibility], + placeholder: '9999', + width: 'sm', + inputmode: 'numeric' + }, + { + key: 'metar-temp', + label: 'Temp/Dew', + expected: scenario => scenario.metarSegments.temp, + alternatives: scenario => [ + `${formatTemp(scenario.temperature)}/${formatTemp(scenario.dewpoint)}` + ], + width: 'md' + }, + { + key: 'metar-qnh', + label: 'QNH', + expected: scenario => scenario.metarSegments.qnh, + alternatives: scenario => [`Q${scenario.qnh}`, scenario.qnh.toString()], + width: 'sm' + } + ], + readback: [ + { type: 'text', text: 'Wind ' }, + { type: 'field', key: 'metar-wind', width: 'md' }, + { type: 'text', text: ', visibility ' }, + { type: 'field', key: 'metar-vis', width: 'sm' }, + { type: 'text', text: ', temperature ' }, + { type: 'field', key: 'metar-temp', width: 'md' }, + { type: 'text', text: ', QNH ' }, + { type: 'field', key: 'metar-qnh', width: 'sm' } + ], + defaultFrequency: 'ATIS', + phrase: scenario => scenario.metar, + info: scenario => [ + `METAR: ${scenario.metar}`, + `Interpretation: Wind ${scenario.metarSegments.wind}, visibility ${scenario.visibilityWords}, temperature ${scenario.temperature}°C, QNH ${scenario.qnh}` + ], + generate: createBaseScenario + }, + { + id: 'radio-check', + title: 'Radio Check', + desc: 'Report readability', + keywords: ['Ground', 'Comms'], + hints: [ + 'Reply with "Readability" followed by the number.', + 'Always finish the check with your call sign.' + ], + fields: [ + { + key: 'rc-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + placeholder: 'Lufthansa one two three', + width: 'lg' + }, + { + key: 'rc-readability', + label: 'Readability', + expected: scenario => scenario.readabilityWord, + alternatives: scenario => [scenario.readability.toString(), scenario.readabilityWord], + placeholder: 'five', + width: 'sm' + } + ], + readback: [ + { type: 'text', text: 'This is ' }, + { type: 'field', key: 'rc-callsign', width: 'lg' }, + { type: 'text', text: ', readability ' }, + { type: 'field', key: 'rc-readability', width: 'sm' } + ], + defaultFrequency: 'GND', + phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`, + info: scenario => [ + `Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`, + `Expected response: Readability ${scenario.readability} (${scenario.readabilityWord})` + ], + generate: createBaseScenario + } + ] + }, + { + id: 'arc', + title: 'ARC Decision Tree', + subtitle: 'From clearance call to departure', + art: gradientArt(['#f97316', '#fb923c', '#0f172a']), + lessons: [ + { + id: 'clearance-contact', + title: 'Delivery: Initial contact', + desc: 'Contact clearance delivery', + keywords: ['Delivery', 'Clearance'], + hints: [ + 'Order: unit, call sign, ATIS, destination, stand, request.', + 'Say the ATIS as a single letter.' + ], + fields: [ + { + key: 'cd-atis', + label: 'ATIS', + expected: scenario => scenario.atisCode, + alternatives: scenario => [ + scenario.atisCode, + scenario.atisCode.toLowerCase(), + `Information ${scenario.atisCode}` + ], + width: 'xs', + threshold: 0.9 + }, + { + key: 'cd-dest', + label: 'Destination', + expected: scenario => scenario.destination.city, + alternatives: scenario => [scenario.destination.icao, scenario.destination.name], + width: 'md' + }, + { + key: 'cd-stand', + label: 'Stand/Gate', + expected: scenario => scenario.stand, + width: 'sm' + } + ], + readback: [ + { + type: 'text', + text: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information ` + }, + { type: 'field', key: 'cd-atis', width: 'xs' }, + { type: 'text', text: ', IFR to ' }, + { type: 'field', key: 'cd-dest', width: 'md' }, + { type: 'text', text: ', stand ' }, + { type: 'field', key: 'cd-stand', width: 'sm' }, + { type: 'text', text: ', request clearance.' } + ], + defaultFrequency: 'DEL', + phrase: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information ${scenario.atisCode}, IFR to ${scenario.destination.city}, stand ${scenario.stand}, request clearance.`, + info: scenario => [ + `ATIS: ${scenario.atisCode}`, + `Destination: ${scenario.destination.city} (${scenario.destination.icao})`, + `Stand/Gate: ${scenario.stand}` + ], + generate: createBaseScenario + }, + { + id: 'clearance-readback', + title: 'Clearance Readback', + desc: 'Read back the clearance in full', + keywords: ['Delivery', 'Readback'], + hints: [ + 'Remember the order: destination – SID – runway – altitude – squawk.', + 'Speak altitude and squawk digits clearly.' + ], + fields: [ + { + key: 'clr-dest', + label: 'Destination', + expected: scenario => scenario.destination.city, + alternatives: scenario => [scenario.destination.icao, scenario.destination.name], + width: 'md' + }, + { + key: 'clr-sid', + label: 'SID', + expected: scenario => scenario.sid, + width: 'lg' + }, + { + key: 'clr-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'clr-alt', + label: 'Initial Altitude', + expected: scenario => scenario.altitudes.initialWords, + alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`], + width: 'md' + }, + { + key: 'clr-squawk', + label: 'Squawk', + expected: scenario => scenario.squawkWords, + alternatives: scenario => [scenario.squawk, scenario.squawk.split('').join(' ')], + width: 'md' + } + ], + readback: [ + { type: 'text', text: scenario => `${scenario.radioCall} cleared ` }, + { type: 'field', key: 'clr-dest', width: 'md' }, + { type: 'text', text: ' via ' }, + { type: 'field', key: 'clr-sid', width: 'lg' }, + { type: 'text', text: ', runway ' }, + { type: 'field', key: 'clr-runway', width: 'sm' }, + { type: 'text', text: ', climb ' }, + { type: 'field', key: 'clr-alt', width: 'md' }, + { type: 'text', text: ', squawk ' }, + { type: 'field', key: 'clr-squawk', width: 'md' } + ], + defaultFrequency: 'DEL', + phrase: scenario => `${scenario.radioCall}, cleared to ${scenario.destination.city} via ${scenario.sid}, runway ${scenario.runway}, climb ${scenario.altitudes.initial} feet, squawk ${scenario.squawk}.`, + info: scenario => [ + `SID: ${scenario.sid} (${scenario.transition})`, + `Initial Altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`, + `Squawk: ${scenario.squawk} (${scenario.squawkWords})` + ], + generate: createBaseScenario + }, + { + id: 'pushback', + title: 'Push & Start Readback', + desc: 'Acknowledge the pushback clearance', + keywords: ['Ground', 'Pushback'], + hints: [ + 'Repeat the runway direction and QNH, call sign at the end.', + 'QNH may be just the number or "QNH xxxx".' + ], + fields: [ + { + key: 'push-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'push-qnh', + label: 'QNH', + expected: scenario => scenario.qnh.toString(), + alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords], + width: 'sm' + }, + { + key: 'push-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Push and start approved, facing runway ' }, + { type: 'field', key: 'push-runway', width: 'sm' }, + { type: 'text', text: ', QNH ' }, + { type: 'field', key: 'push-qnh', width: 'sm' }, + { type: 'text', text: ', ' }, + { type: 'field', key: 'push-callsign', width: 'lg' } + ], + defaultFrequency: 'GND', + phrase: scenario => `${scenario.radioCall}, push and start approved, facing runway ${scenario.runway}. QNH ${scenario.qnh}.`, + info: scenario => [ + `Stand/Gate: ${scenario.stand}`, + `Ground: ${scenario.groundFreq} (${scenario.frequencyWords.GND})` + ], + generate: createBaseScenario + }, + { + id: 'taxi', + title: 'Taxi Readback', + desc: 'Read back the taxi clearance with hold short', + keywords: ['Ground', 'Taxi'], + hints: [ + 'Repeat the route exactly as received, including the hold short.', + 'Finish with the call sign.' + ], + fields: [ + { + key: 'taxi-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'taxi-route', + label: 'Route', + expected: scenario => scenario.taxiRoute, + alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`], + width: 'lg' + }, + { + key: 'taxi-hold', + label: 'Hold Short', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'taxi-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Taxi to runway ' }, + { type: 'field', key: 'taxi-runway', width: 'sm' }, + { type: 'text', text: ' via ' }, + { type: 'field', key: 'taxi-route', width: 'lg' }, + { type: 'text', text: ', holding short runway ' }, + { type: 'field', key: 'taxi-hold', width: 'sm' }, + { type: 'text', text: ', ' }, + { type: 'field', key: 'taxi-callsign', width: 'lg' } + ], + defaultFrequency: 'GND', + phrase: scenario => `${scenario.radioCall}, taxi to runway ${scenario.runway} via ${scenario.taxiRoute}, hold short runway ${scenario.runway}.`, + info: scenario => [ + `Taxi route: ${scenario.taxiRoute}`, + `Hold short: ${scenario.runway}` + ], + generate: createBaseScenario + }, + { + id: 'lineup', + title: 'Line-up Clearance', + desc: 'Acknowledge line up and wait', + keywords: ['Tower', 'Line Up'], + hints: [ + 'Repeat the runway, then say "line up and wait".', + 'Place the call sign at the end.' + ], + fields: [ + { + key: 'lineup-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'lineup-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Runway ' }, + { type: 'field', key: 'lineup-runway', width: 'sm' }, + { type: 'text', text: ', line up and wait, ' }, + { type: 'field', key: 'lineup-callsign', width: 'lg' } + ], + defaultFrequency: 'TWR', + phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`, + info: scenario => [ + `Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`, + `Line-up: runway ${scenario.runway}` + ], + generate: createBaseScenario + }, + { + id: 'takeoff', + title: 'Takeoff Clearance', + desc: 'Acknowledge the takeoff clearance', + keywords: ['Tower', 'Departure'], + hints: [ + 'Order: runway – cleared for takeoff – call sign.', + 'Wind information can be omitted if it was not given.' + ], + fields: [ + { + key: 'tko-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'tko-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Runway ' }, + { type: 'field', key: 'tko-runway', width: 'sm' }, + { type: 'text', text: ', cleared for takeoff, ' }, + { type: 'field', key: 'tko-callsign', width: 'lg' } + ], + defaultFrequency: 'TWR', + phrase: scenario => `${scenario.radioCall}, wind ${scenario.windWords}, runway ${scenario.runway}, cleared for takeoff.`, + info: scenario => [ + `Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`, + `Wind: ${scenario.wind} (${scenario.windWords})` + ], + generate: createBaseScenario + }, + { + id: 'departure-handoff', + title: 'Departure Handoff', + desc: 'Switch to departure', + keywords: ['Departure', 'Handoff'], + hints: [ + 'Repeat the frequency exactly, either as digits or spoken form.', + 'Append the call sign at the end.' + ], + fields: [ + { + key: 'dep-freq', + label: 'Frequenz', + expected: scenario => scenario.departureFreq, + alternatives: scenario => [ + scenario.departureFreq, + scenario.departureFreq.replace('.', ''), + scenario.frequencyWords.DEP + ], + width: 'md' + }, + { + key: 'dep-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Contact departure ' }, + { type: 'field', key: 'dep-freq', width: 'md' }, + { type: 'text', text: ', ' }, + { type: 'field', key: 'dep-callsign', width: 'lg' } + ], + defaultFrequency: 'DEP', + phrase: scenario => `${scenario.radioCall}, contact departure ${scenario.departureFreq}.`, + info: scenario => [ + `Departure: ${scenario.departureFreq} (${scenario.frequencyWords.DEP})`, + `Tower handoff after departure.` + ], + generate: createBaseScenario + }, + { + id: 'departure-checkin', + title: 'Departure Check-in', + desc: 'Initial call to departure', + keywords: ['Departure', 'Check-in'], + hints: [ + 'Address the unit first, then state the call sign.', + 'Repeat the altitude and SID exactly as received.' + ], + fields: [ + { + key: 'depcheck-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + }, + { + key: 'depcheck-alt', + label: 'Altitude', + expected: scenario => scenario.altitudes.initialWords, + alternatives: scenario => [ + scenario.altitudes.initialWords, + scenario.altitudes.initial.toString(), + `${scenario.altitudes.initial} feet` + ], + width: 'md' + }, + { + key: 'depcheck-sid', + label: 'SID', + expected: scenario => scenario.sid, + width: 'lg' + } + ], + readback: [ + { type: 'text', text: scenario => `${scenario.airport.city} Departure, ` }, + { type: 'field', key: 'depcheck-callsign', width: 'lg' }, + { type: 'text', text: ', passing ' }, + { type: 'field', key: 'depcheck-alt', width: 'md' }, + { type: 'text', text: ', on SID ' }, + { type: 'field', key: 'depcheck-sid', width: 'lg' } + ], + defaultFrequency: 'DEP', + phrase: scenario => `${scenario.airport.city} Departure, ${scenario.radioCall}, passing ${scenario.altitudes.initialWords}, on SID ${scenario.sid}.`, + info: scenario => [ + `Initial Altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`, + `SID: ${scenario.sid}` + ], + generate: createBaseScenario + }, + { + id: 'climb-instruction', + title: 'Climb & Direct', + desc: 'Read back the climb instruction in full', + keywords: ['Departure', 'Climb'], + hints: [ + 'Start with "Climb", then the altitude and any direct clearance.', + 'Repeat the call sign at the end.' + ], + fields: [ + { + key: 'climb-alt', + label: 'Altitude', + expected: scenario => scenario.altitudes.climbWords, + alternatives: scenario => [ + scenario.altitudes.climbWords, + scenario.altitudes.climb.toString(), + `${scenario.altitudes.climb} feet` + ], + width: 'md' + }, + { + key: 'climb-direct', + label: 'Direct', + expected: scenario => scenario.transition, + width: 'md' + }, + { + key: 'climb-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Climb ' }, + { type: 'field', key: 'climb-alt', width: 'md' }, + { type: 'text', text: ', direct ' }, + { type: 'field', key: 'climb-direct', width: 'md' }, + { type: 'text', text: ', ' }, + { type: 'field', key: 'climb-callsign', width: 'lg' } + ], + defaultFrequency: 'DEP', + phrase: scenario => `${scenario.radioCall}, climb ${scenario.altitudes.climb} feet, proceed direct ${scenario.transition}.`, + info: scenario => [ + `Climb: ${scenario.altitudes.climb} ft (${scenario.altitudes.climbWords})`, + `Direct to: ${scenario.transition}` + ], + generate: createBaseScenario + }, + { + id: 'center-handoff', + title: 'Center Handoff', + desc: 'Acknowledge the handoff to center', + keywords: ['Center', 'Handoff'], + hints: [ + 'Repeat the frequency exactly (with or without the decimal point).', + 'Place the call sign at the end.' + ], + fields: [ + { + key: 'ctr-freq', + label: 'Frequenz', + expected: scenario => scenario.centerFreq, + alternatives: scenario => [ + scenario.centerFreq, + scenario.centerFreq.replace('.', ''), + scenario.frequencyWords.CTR + ], + width: 'md' + }, + { + key: 'ctr-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Contact center ' }, + { type: 'field', key: 'ctr-freq', width: 'md' }, + { type: 'text', text: ', ' }, + { type: 'field', key: 'ctr-callsign', width: 'lg' } + ], + defaultFrequency: 'CTR', + phrase: scenario => `${scenario.radioCall}, contact center ${scenario.centerFreq}.`, + info: scenario => [ + `Center: ${scenario.centerFreq} (${scenario.frequencyWords.CTR})`, + `Next unit: Center` + ], + generate: createBaseScenario + } + ] + } +] +export default learnModules diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts new file mode 100644 index 0000000..877ab4e --- /dev/null +++ b/shared/learn/scenario.ts @@ -0,0 +1,377 @@ +import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } from './types' + +const natoMap: Record = { + A: 'Alpha', + B: 'Bravo', + C: 'Charlie', + D: 'Delta', + E: 'Echo', + F: 'Foxtrot', + G: 'Golf', + H: 'Hotel', + I: 'India', + J: 'Juliett', + K: 'Kilo', + L: 'Lima', + M: 'Mike', + N: 'November', + O: 'Oscar', + P: 'Papa', + Q: 'Quebec', + R: 'Romeo', + S: 'Sierra', + T: 'Tango', + U: 'Uniform', + V: 'Victor', + W: 'Whiskey', + X: 'X-ray', + Y: 'Yankee', + Z: 'Zulu' +} + +const atcNumberWords: Record = { + '0': 'zero', + '1': 'one', + '2': 'two', + '3': 'tree', + '4': 'four', + '5': 'fife', + '6': 'six', + '7': 'seven', + '8': 'eight', + '9': 'niner' +} + +const runwaySuffixWords: Record = { + L: 'left', + R: 'right', + C: 'center' +} + +const readabilityScale = [ + { level: 1, word: 'one', description: 'Unreadable' }, + { level: 2, word: 'two', description: 'Barely readable' }, + { level: 3, word: 'three', description: 'Readable with difficulty' }, + { level: 4, word: 'four', description: 'Readable' }, + { level: 5, word: 'five', description: 'Perfectly readable' } +] + +const airlines: AirlineData[] = [ + { code: 'DLH', call: 'Lufthansa' }, + { code: 'BAW', call: 'Speedbird' }, + { code: 'AFR', call: 'Air France' }, + { code: 'KLM', call: 'KLM' }, + { code: 'SWR', call: 'Swiss' }, + { code: 'EZY', call: 'Easyjet' } +] + +const airportsData: AirportData[] = [ + { + icao: 'EDDF', + name: 'Frankfurt/Main', + city: 'Frankfurt', + runways: ['25C', '25R', '07C', '07R', '18'], + stands: ['V155', 'A12', 'B24', 'G5', 'H43', 'L21'], + sids: ['ANEKI 7S', 'TOBAK 5Q', 'OBOKA 6N'], + transitions: ['ANEKI', 'TOBAK', 'OBOKA'], + approaches: ['ILS Z 25C', 'ILS Y 07C'], + taxi: ['N3 U4', 'V A', 'N7 K', 'S V12'], + freqs: { + atis: '126.350', + delivery: '121.900', + ground: '121.800', + tower: '118.700', + departure: '125.350', + approach: '120.800', + center: '134.200' + }, + transLevel: 'FL070' + }, + { + icao: 'EDDM', + name: 'Munich', + city: 'Munich', + runways: ['26R', '26L', '08R', '08L'], + stands: ['211', '214', '302', 'N16', 'H45'], + sids: ['OBAXA 3S', 'MERSI 6S', 'TULSI 5M'], + transitions: ['OBAXA', 'MERSI', 'TULSI'], + approaches: ['ILS Z 26R', 'ILS Y 08R'], + taxi: ['L4 N3', 'P3 W2', 'S1 D2'], + freqs: { + atis: '122.130', + delivery: '121.775', + ground: '121.800', + tower: '118.700', + departure: '129.050', + approach: '120.800', + center: '133.700' + }, + transLevel: 'FL070' + }, + { + icao: 'EHAM', + name: 'Amsterdam Schiphol', + city: 'Amsterdam', + runways: ['24', '36L', '18C', '09'], + stands: ['D14', 'E22', 'F8', 'H4'], + sids: ['SUGOL 2S', 'ANDIK 2S', 'ARNEM 2V'], + transitions: ['SUGOL', 'ANDIK', 'ARNEM'], + approaches: ['ILS Z 24', 'ILS Y 18C'], + taxi: ['A5 B2', 'K1 V3', 'W4 S8'], + freqs: { + atis: '136.050', + delivery: '121.800', + ground: '121.900', + tower: '119.220', + departure: '123.875', + approach: '121.200', + center: '135.050' + }, + transLevel: 'FL060' + } +] + +const atisLetters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ'.split('') + +function choice(items: T[]): T { + return items[Math.floor(Math.random() * items.length)] +} + +function randInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function randomFlightNumber(): string { + const length = Math.random() < 0.5 ? 3 : 4 + let value = '' + for (let i = 0; i < length; i++) { + value += String(randInt(0, 9)) + } + if (value.startsWith('0')) value = '1' + value.slice(1) + return value +} + +function generateSquawk(): string { + let code = '' + for (let i = 0; i < 4; i++) { + code += String(randInt(0, 7)) + } + return code +} + +function digitsToWords(value: string): string { + return value + .split('') + .map(char => atcNumberWords[char] ?? char) + .join(' ') + .replace(/\s+/g, ' ') + .trim() +} + +function lettersToNato(value: string): string { + return value + .toUpperCase() + .split('') + .map(char => natoMap[char] ?? char) + .join(' ') +} + +function runwayToWords(runway: string): string { + const digits = runway.replace(/[^0-9]/g, '').padStart(2, '0') + const suffix = runway.replace(/[0-9]/g, '') + const base = digits + .split('') + .map(char => atcNumberWords[char] ?? char) + .join(' ') + const suffixWord = suffix ? runwaySuffixWords[suffix] ?? suffix.toLowerCase() : '' + return suffixWord ? `${base} ${suffixWord}` : base +} + +function frequencyToSpeech(freq: string): string { + const [intPart, decimalPart] = freq.split('.') + const intWords = digitsToWords(intPart) + if (!decimalPart) return intWords + const trimmed = decimalPart.replace(/0+$/, '') + const decWords = trimmed ? digitsToWords(trimmed) : 'zero' + return `${intWords} decimal ${decWords}` +} + +export function formatTemp(temp: number): string { + const prefix = temp < 0 ? 'M' : '' + return `${prefix}${Math.abs(temp).toString().padStart(2, '0')}` +} + +function temperatureToWords(temp: number): string { + const prefix = temp < 0 ? 'minus' : 'plus' + return `${prefix} ${digitsToWords(Math.abs(temp).toString())}` +} + +function qnhToWords(qnh: number): string { + return digitsToWords(qnh.toString()) +} + +function windToWords(direction: number, speed: number): string { + const dir = direction.toString().padStart(3, '0') + const spd = speed.toString().padStart(2, '0') + return `${dir + .split('') + .map(char => atcNumberWords[char] ?? char) + .join(' ')} degrees at ${spd + .split('') + .map(char => atcNumberWords[char] ?? char) + .join(' ')} knots` +} + +function visibilityToWords(vis: string): string { + if (vis === '9999') return 'ten kilometres or more' + const numeric = Number(vis) + if (!Number.isNaN(numeric)) { + if (numeric >= 1000) { + const km = Math.round(numeric / 1000) + return `${digitsToWords(km.toString())} kilometres` + } + return `${digitsToWords(vis)} metres` + } + return vis +} + +function altitudeToWords(value: number): string { + const thousands = Math.floor(value / 1000) + const remainder = value % 1000 + let words = thousands ? `${digitsToWords(thousands.toString())} thousand` : '' + if (remainder) { + words = `${words} ${digitsToWords(remainder.toString())}`.trim() + } + return words.trim() +} + +export function createBaseScenario(): Scenario { + const airport = choice(airportsData) + const possibleDestinations = airportsData.filter(a => a.icao !== airport.icao) + const destination = choice(possibleDestinations) + const airline = choice(airlines) + const flightNumber = randomFlightNumber() + const callsign = `${airline.code}${flightNumber}` + const runway = choice(airport.runways) + const stand = choice(airport.stands) + const taxiRoute = choice(airport.taxi) + const sid = choice(airport.sids) + const transition = choice(airport.transitions) + const approach = choice(airport.approaches) + const altitude = choice([4000, 5000, 6000, 7000]) + const climbAltitude = altitude + 2000 + const squawk = generateSquawk() + const qnh = randInt(984, 1032) + const windDirection = randInt(0, 35) * 10 + const windSpeed = randInt(3, 18) + const windDirectionStr = windDirection.toString().padStart(3, '0') + const windSpeedStr = windSpeed.toString().padStart(2, '0') + const visibility = choice(['9999', '9000', '8000', '6000']) + const cloud = choice(['SKC', 'FEW020', 'SCT025', 'BKN030']) + const temperature = randInt(-3, 28) + const dewpoint = Math.max(temperature - randInt(2, 6), -10) + const atisCode = choice(atisLetters) + const remarks = choice(['NOSIG', 'BECMG 4000', 'TEMPO -SHRA']) + const now = new Date() + const minute = Math.floor(now.getUTCMinutes() / 5) * 5 + const timestamp = `${now.getUTCDate().toString().padStart(2, '0')}${now + .getUTCHours() + .toString() + .padStart(2, '0')}${minute.toString().padStart(2, '0')}Z` + const metarWindGroup = `${windDirectionStr}${windSpeedStr}KT` + const tempGroup = `${formatTemp(temperature)}/${formatTemp(dewpoint)}` + const metar = `${airport.icao} ${timestamp} ${metarWindGroup} ${visibility} ${cloud} ${tempGroup} Q${qnh + .toString() + .padStart(4, '0')} ${remarks}` + + const frequencies: Frequency[] = [ + { type: 'ATIS', label: 'ATIS', value: airport.freqs.atis }, + { type: 'DEL', label: 'Delivery', value: airport.freqs.delivery }, + { type: 'GND', label: 'Ground', value: airport.freqs.ground }, + { type: 'TWR', label: 'Tower', value: airport.freqs.tower }, + { type: 'DEP', label: 'Departure', value: airport.freqs.departure }, + { type: 'APP', label: 'Approach', value: airport.freqs.approach }, + { type: 'CTR', label: 'Center', value: airport.freqs.center } + ] + + const frequencyWords = frequencies.reduce((acc, freq) => { + acc[freq.type] = frequencyToSpeech(freq.value) + return acc + }, {} as Record) + + const readability = choice(readabilityScale) + + const atisText = `${airport.name} information ${atisCode}, time ${timestamp.slice(2, 4)}${timestamp.slice(4, 6)}, runway ${ + runwayToWords(runway) + } in use, wind ${windToWords(windDirection, windSpeed)}, visibility ${visibilityToWords(visibility)}, temperature ${ + temperatureToWords(temperature) + }, dew point ${temperatureToWords(dewpoint)}, QNH ${qnh}, transition level ${airport.transLevel.replace('FL', '')}.` + + return { + callsign, + airlineCode: airline.code, + airlineCall: airline.call, + radioCall: `${airline.call} ${digitsToWords(flightNumber)}`, + callsignNato: lettersToNato(airline.code), + flightNumber, + flightNumberWords: digitsToWords(flightNumber), + airport, + destination, + runway, + runwayWords: runwayToWords(runway), + stand, + taxiRoute, + sid, + transition, + approach, + altitudes: { + initial: altitude, + climb: climbAltitude, + initialWords: altitudeToWords(altitude), + climbWords: altitudeToWords(climbAltitude) + }, + squawk, + squawkWords: digitsToWords(squawk), + qnh, + qnhWords: qnhToWords(qnh), + atisCode, + atisText, + atisSummary: { + runway, + wind: `${windDirectionStr}/${windSpeedStr}`, + visibility, + temperature: `${temperature}°C`, + dewpoint: `${dewpoint}°C`, + qnh: `QNH ${qnh}` + }, + wind: `${windDirectionStr}/${windSpeedStr}`, + windWords: windToWords(windDirection, windSpeed), + visibility, + visibilityWords: visibilityToWords(visibility), + temperature, + temperatureWords: temperatureToWords(temperature), + dewpoint, + dewpointWords: temperatureToWords(dewpoint), + metar, + metarSegments: { + wind: metarWindGroup, + visibility, + temp: tempGroup, + qnh: `Q${qnh.toString().padStart(4, '0')}` + }, + readability: readability.level, + readabilityWord: readability.word, + readabilityPhrase: `Readability ${readability.word}`, + frequencies, + frequencyWords, + atisFreq: airport.freqs.atis, + deliveryFreq: airport.freqs.delivery, + groundFreq: airport.freqs.ground, + towerFreq: airport.freqs.tower, + departureFreq: airport.freqs.departure, + approachFreq: airport.freqs.approach, + centerFreq: airport.freqs.center, + transLevel: airport.transLevel, + remarks + } +} diff --git a/shared/learn/types.ts b/shared/learn/types.ts new file mode 100644 index 0000000..20cdd8d --- /dev/null +++ b/shared/learn/types.ts @@ -0,0 +1,141 @@ +export type BlankWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +export type FrequencyType = 'ATIS' | 'DEL' | 'GND' | 'TWR' | 'DEP' | 'APP' | 'CTR' + +export type Frequency = { + type: FrequencyType + label: string + value: string +} + +export type AirportData = { + icao: string + name: string + city: string + runways: string[] + stands: string[] + sids: string[] + transitions: string[] + approaches: string[] + taxi: string[] + freqs: { + atis: string + delivery: string + ground: string + tower: string + departure: string + approach: string + center: string + } + transLevel: string +} + +export type AirlineData = { + code: string + call: string +} + +export type Scenario = { + callsign: string + airlineCode: string + airlineCall: string + radioCall: string + callsignNato: string + flightNumber: string + flightNumberWords: string + airport: AirportData + destination: AirportData + runway: string + runwayWords: string + stand: string + taxiRoute: string + sid: string + transition: string + approach: string + altitudes: { + initial: number + climb: number + initialWords: string + climbWords: string + } + squawk: string + squawkWords: string + qnh: number + qnhWords: string + atisCode: string + atisText: string + atisSummary: { + runway: string + wind: string + visibility: string + temperature: string + dewpoint: string + qnh: string + } + wind: string + windWords: string + visibility: string + visibilityWords: string + temperature: number + temperatureWords: string + dewpoint: number + dewpointWords: string + metar: string + metarSegments: { + wind: string + visibility: string + temp: string + qnh: string + } + readability: number + readabilityWord: string + readabilityPhrase: string + frequencies: Frequency[] + frequencyWords: Record + atisFreq: string + deliveryFreq: string + groundFreq: string + towerFreq: string + departureFreq: string + approachFreq: string + centerFreq: string + transLevel: string + remarks: string +} + +export type LessonField = { + key: string + label: string + expected: (scenario: Scenario) => string + alternatives?: (scenario: Scenario) => string[] + threshold?: number + placeholder?: string + width?: BlankWidth + inputmode?: 'text' | 'numeric' +} + +export type ReadbackSegment = + | { type: 'text'; text: string | ((scenario: Scenario) => string) } + | { type: 'field'; key: string; width?: BlankWidth } + +export type Lesson = { + id: string + title: string + desc: string + keywords: string[] + hints: string[] + fields: LessonField[] + readback: ReadbackSegment[] + defaultFrequency?: string + phrase: (scenario: Scenario) => string + info: (scenario: Scenario) => string[] + generate: () => Scenario +} + +export type ModuleDef = { + id: string + title: string + subtitle: string + art: string + lessons: Lesson[] +}