This commit is contained in:
itsrubberduck
2025-09-19 08:55:49 +02:00
5 changed files with 150 additions and 237 deletions

View File

@@ -10,80 +10,53 @@
>
<section class="cookie-consent-panel">
<header class="cookie-consent-header">
<h2 id="cookie-consent-title">{{ isGerman ? 'Cookies &amp; Analysen' : 'Cookies &amp; analytics' }}</h2>
<h2 id="cookie-consent-title">Cookies &amp; Analytics</h2>
<p id="cookie-consent-description">
<template v-if="isGerman">
Wir verwenden notwendige Cookies, damit die Website zuverlässig funktioniert. Darüber hinaus würden wir gern optionale
Analyse-Cookies von Hotjar einsetzen, um das Nutzungserlebnis zu verbessern. Sie entscheiden, ob Sie diese zulassen alle
Details finden Sie in unserer
</template>
<template v-else>
We use essential cookies to keep the site reliable. We would also like to use optional Hotjar analytics cookies to improve your
experience. You decide whether to allow themsee our
</template>
<a href="/datenschutz" target="_blank" rel="noopener">{{ isGerman ? 'Datenschutzerklärung' : 'privacy policy' }}</a>.
We use essential cookies to keep the site reliable. We would also like to enable optional Hotjar analytics cookies to
improve the experience. You decide whether to allow themfull details are available in our
<a href="/datenschutz" target="_blank" rel="noopener">privacy policy</a>.
</p>
</header>
<div class="cookie-consent-options" role="list">
<article class="cookie-option is-required" role="listitem">
<div class="cookie-option-text">
<h3>{{ isGerman ? 'Notwendige Cookies' : 'Essential cookies' }}</h3>
<p>
{{
isGerman
? 'Speichern Ihre Auswahl und sorgen für sichere Anmeldung sowie eine stabile Grundfunktionalität.'
: 'Store your selection and enable secure sign-in plus a stable core experience.'
}}
</p>
<h3>Necessary cookies</h3>
<p>Store your preferences and keep sign-in secure while ensuring the core experience remains stable.</p>
</div>
<span class="cookie-option-badge" aria-hidden="true">{{ isGerman ? 'Immer aktiv' : 'Always on' }}</span>
<span class="cookie-option-badge" aria-hidden="true">Always active</span>
</article>
<article class="cookie-option" role="listitem">
<div class="cookie-option-text">
<h3>{{ isGerman ? 'Analyse (Hotjar)' : 'Analytics (Hotjar)' }}</h3>
<p>
{{
isGerman
? 'Hilft uns, Nutzungsverhalten anonym auszuwerten und unsere Inhalte gezielt zu verbessern.'
: 'Helps us analyse anonymised usage to improve content and product decisions.'
}}
</p>
<h3>Analytics (Hotjar)</h3>
<p>Helps us analyse anonymised usage patterns and improve our content in a focused way.</p>
</div>
<button
type="button"
class="cookie-toggle"
role="switch"
:aria-checked="analyticsSelection"
@click="toggleAnalytics"
type="button"
class="cookie-toggle"
role="switch"
:aria-checked="analyticsSelection"
@click="toggleAnalytics"
>
<span class="cookie-toggle-track" :class="{ 'is-active': analyticsSelection }">
<span class="cookie-toggle-thumb" />
</span>
<span class="sr-only">
{{
isGerman
? `Analyse-Cookies ${analyticsSelection ? 'deaktivieren' : 'aktivieren'}`
: `${analyticsSelection ? 'Disable' : 'Enable'} analytics cookies`
}}
{{ analyticsSelection ? 'Disable analytics cookies' : 'Enable analytics cookies' }}
</span>
</button>
</article>
</div>
<p class="cookie-consent-note">
{{
isGerman
? 'Optionales Tracking wird erst nach Ihrer Zustimmung geladen. Sie können Ihre Entscheidung jederzeit über „Cookie-Einstellungen“ unten links widerrufen.'
: 'Optional tracking only loads after you consent. You can revisit your choice at any time via “Cookie settings” in the lower left.'
}}
Optional tracking loads only after you agree. You can change your decision any time via "Cookie settings" in the lower left.
</p>
<div class="cookie-consent-actions">
<button type="button" class="cookie-button ghost" @click="handleRejectAll">{{ isGerman ? 'Nur notwendige' : 'Only necessary' }}</button>
<button type="button" class="cookie-button secondary" @click="handleSave">{{ isGerman ? 'Auswahl speichern' : 'Save selection' }}</button>
<button type="button" class="cookie-button primary" @click="handleAcceptAll">{{ isGerman ? 'Alle akzeptieren' : 'Accept all' }}</button>
<button type="button" class="cookie-button ghost" @click="handleRejectAll">Essential only</button>
<button type="button" class="cookie-button secondary" @click="handleSave">Save selection</button>
<button type="button" class="cookie-button primary" @click="handleAcceptAll">Accept all</button>
</div>
</section>
</div>
@@ -91,22 +64,19 @@
<transition name="cookie-manage">
<button v-if="showManageButton" type="button" class="cookie-manage-button" @click="openManager">
{{ isGerman ? 'Cookie-Einstellungen' : 'Cookie settings' }}
Cookie settings
</button>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useState } from '#imports';
const { hasConsent, analyticsEnabled, acceptAll, rejectAll, savePreferences } = useCookieConsent();
const isDialogVisible = ref(!hasConsent.value);
const isManuallyOpened = ref(false);
const analyticsSelection = ref(analyticsEnabled.value);
const locale = useState<'de' | 'en'>('landing-locale', () => 'de');
const isGerman = computed(() => locale.value === 'de');
watch(
() => hasConsent.value,

View File

@@ -10,7 +10,7 @@
</button>
<span class="brand">OpenSquawk</span>
<span class="sep">|</span>
<span class="mode">Training</span>
<span class="mode">Training Hub</span>
</div>
<div class="hud-right">
@@ -20,20 +20,8 @@
<!-- Quick ATC: Voice & Level -->
<div class="chip inline">
<v-icon size="16" class="text-accent">mdi-radio-handheld</v-icon>
<span class="ml-1">Level {{ cfg.radioLevel }}</span>
<input type="range" min="1" max="5" step="1" v-model.number="cfg.radioLevel" aria-label="Radio level"/>
</div>
<div class="chip inline">
<v-icon size="16" class="text-accent">mdi-speedometer</v-icon>
<span class="ml-1">Speed {{ cfg.audioSpeed.toFixed(1) }}x</span>
<input
type="range"
min="0.7"
max="1.3"
step="0.1"
v-model.number="cfg.audioSpeed"
aria-label="ATC audio speed"
/>
<span class="ml-1">L {{ cfg.radioLevel }}</span>
<input type="range" min="1" max="5" step="1" v-model.number="cfg.radioLevel"/>
</div>
<button class="btn ghost" @click="panel='progress'">
@@ -61,12 +49,12 @@
<div class="panel hero-panel" :style="worldTiltStyle" @mousemove="tilt">
<div class="hero-left">
<div class="eyebrow">ALPHA BUILD · TRAINING</div>
<h1 class="h1">ATC Training World</h1>
<h1 class="h1">ATC Learning Hub</h1>
<p class="muted">Guided paths · missions · XP · badges. Stored locally.</p>
<div class="actions">
<button class="btn primary" @click="panel='hub'">
<v-icon>mdi-play</v-icon>
Start Training
Start training
</button>
<button class="btn ghost" @click="panel='progress'">
<v-icon>mdi-progress-check</v-icon>
@@ -110,7 +98,7 @@
<main v-if="panel==='hub'" class="container" role="main">
<div class="hub-head">
<h2 class="h2">Mission Hub</h2>
<div class="muted">Start with the ICAO alphabet & numbers, then basics, ground, and beyond.</div>
<div class="muted">Start with the ICAO alphabet & numbers, then basics, ground, and more.</div>
</div>
<div class="tiles">
@@ -179,7 +167,7 @@
<v-icon size="18">mdi-headset</v-icon>
{{ l.title }}
</div>
<div class="chip" :class="{ ok: bestScore(current.id,l.id)>=80 }">
<div class="chip" :class="{ ok: bestScore(current.id,l.id)>=80 }">
{{ bestScore(current.id, l.id) ? bestScore(current.id, l.id) + '%' : 'new' }}
</div>
</div>
@@ -236,7 +224,7 @@
{{ targetPhrase }}
</div>
<div v-if="audioContentHidden" class="audio-note muted small">
Audio Challenge active listen first.
Audio challenge active listen first.
</div>
</div>
<div class="row wrap">
@@ -257,7 +245,7 @@
@click="revealAudioContent"
>
<v-icon size="16">mdi-eye</v-icon>
Show
Reveal text
</button>
<button class="btn ghost mini" type="button" @click="rollScenario(true)">
<v-icon size="16">mdi-dice-5</v-icon>
@@ -284,7 +272,7 @@
</div>
<div class="col">
<div class="label">Your Readback</div>
<div class="label">Your readback</div>
<div class="panel readback-panel">
<div class="cloze">
<template v-for="(segment, idx) in activeLesson.readback" :key="segment.type === 'field' ? `f-${segment.key}` : `t-${idx}`">
@@ -306,7 +294,7 @@
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert</v-icon>
<small v-if="result" class="blank-feedback">
Expected: {{ fieldExpectedValue(segment.key) }}
Soll: {{ fieldExpectedValue(segment.key) }}
</small>
</label>
</template>
@@ -323,16 +311,7 @@
</button>
<button class="btn ghost" type="button" @click="fillSolution">
<v-icon size="18">mdi-auto-fix</v-icon>
Autofill
</button>
<button
v-if="result"
class="btn soft"
type="button"
@click="goToNextLesson"
>
<v-icon size="18">mdi-arrow-right-bold</v-icon>
{{ nextLesson ? 'Next Lesson' : 'Back to Hub' }}
Auto-fill
</button>
</div>
<div v-if="result" class="score">
@@ -354,7 +333,7 @@
<!-- PROGRESS -->
<section v-if="panel==='progress'" class="container" aria-label="Progress">
<h2 class="h2">Overall Progress</h2>
<h2 class="h2">Overall progress</h2>
<div class="progress-grid">
<div v-for="m in modules" :key="m.id" class="panel">
<div class="row between">
@@ -374,21 +353,21 @@
<!-- SETTINGS DIALOG -->
<v-dialog v-model="showSettings" max-width="720">
<div class="panel dialog">
<h3 class="h3">ATC Settings</h3>
<h3 class="h3">ATC settings</h3>
<div class="settings">
<div class="set-row">
<div class="set-info">
<span>Browser-TTS (Web Speech)</span>
<small class="muted">Works offline, starts faster, and saves our API calls.</small>
<span>Browser TTS (Web Speech)</span>
<small class="muted">Runs offline, starts faster, and saves our API calls.</small>
</div>
<v-switch v-model="cfg.tts" color="cyan" hide-details inset/>
</div>
<div class="set-row">
<div class="set-info">
<span>Audio Challenge</span>
<small class="muted">Hides target text & details until you reveal them manually.</small>
<span>Audio challenge</span>
<small class="muted">Hides target text & info until you reveal it manually.</small>
</div>
<v-switch v-model="cfg.audioChallenge" color="cyan" hide-details inset/>
</div>
@@ -398,21 +377,6 @@
<v-slider v-model="cfg.radioLevel" :min="1" :max="5" :step="1" color="cyan" thumb-label/>
</div>
<div class="set-row">
<div class="set-info">
<span>ATC audio speed</span>
<small class="muted">Fine-tune the playback speed for ATC voice lines.</small>
</div>
<v-slider
v-model="cfg.audioSpeed"
:min="0.7"
:max="1.3"
:step="0.1"
color="cyan"
thumb-label
/>
</div>
<div class="set-row">
<span>Voice</span>
<v-text-field v-model="cfg.voice" placeholder="alloy" hide-details density="compact" variant="outlined"/>
@@ -704,7 +668,7 @@ const airportsData: AirportData[] = [
},
{
icao: 'EDDM',
name: 'München',
name: 'Munich',
city: 'Munich',
runways: ['26R', '26L', '08R', '08L'],
stands: ['211', '214', '302', 'N16', 'H45'],
@@ -1054,8 +1018,8 @@ const modules = ref<ModuleDef[]>([
desc: 'Spell letters and digits clearly',
keywords: ['Alphabet', 'Numbers', 'Normalize'],
hints: [
'Spell each letter with the ICAO name (e.g. Delta, Lima, Hotel).',
'Digits on the radio: tree, fower, fife, niner.'
'Spell each letter with its ICAO name (e.g. Delta, Lima, Hotel).',
'Use ATC numbers: tree, fower, fife, niner.'
],
fields: [
{
@@ -1068,7 +1032,7 @@ const modules = ref<ModuleDef[]>([
},
{
key: 'digits',
label: 'Digits',
label: 'Numbers',
expected: scenario => scenario.flightNumberWords,
placeholder: 'one two three',
width: 'lg',
@@ -1076,7 +1040,7 @@ const modules = ref<ModuleDef[]>([
}
],
readback: [
{ type: 'text', text: 'Callsign: ' },
{ type: 'text', text: 'Call sign: ' },
{ type: 'field', key: 'letters', width: 'lg' },
{ type: 'text', text: ' · ' },
{ type: 'field', key: 'digits', width: 'md' }
@@ -1084,20 +1048,20 @@ const modules = ref<ModuleDef[]>([
defaultFrequency: 'DEL',
phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`,
info: scenario => [
`Registration: ${scenario.callsign}`,
`Airline call: ${scenario.radioCall}`,
`ICAO: ${scenario.callsignNato}`,
`Callsign: ${scenario.callsign}`,
`Radio call: ${scenario.radioCall}`,
`ICAO spelling: ${scenario.callsignNato}`,
`Flight number spoken: ${scenario.flightNumberWords}`
],
generate: createBaseScenario
},
{
id: 'atis',
title: 'Understand ATIS',
desc: 'Extract the identifier and core data from the ATIS',
title: 'Understand the ATIS',
desc: 'Extract the identifier and key data from the ATIS',
keywords: ['ATIS', 'Weather'],
hints: [
'Remember the ATIS designator as a single letter.',
'Remember the ATIS identifier as a single letter.',
'Order: runway wind visibility temperature dew point QNH.'
],
fields: [
@@ -1180,19 +1144,19 @@ const modules = ref<ModuleDef[]>([
defaultFrequency: 'ATIS',
phrase: scenario => scenario.atisText,
info: () => [
'Take note of the designator, runway, wind, visibility, temperature, dew point, and QNH.',
'Visibility: four digits or 9999 for ≥10 km · QNH as a four-digit number.'
'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 METAR',
desc: 'Extract raw METAR values.',
title: 'Decode the METAR',
desc: 'Extract the raw METAR values',
keywords: ['METAR', 'Weather'],
hints: [
'Read METARs in blocks: wind visibility clouds temperature QNH.',
'The temperature block looks like 18/10, negative values start with M.'
'Read the METAR in blocks: wind visibility clouds temperature QNH.',
'The temperature block looks like 18/10; negative values start with M.'
],
fields: [
{
@@ -1256,8 +1220,8 @@ const modules = ref<ModuleDef[]>([
desc: 'Report readability',
keywords: ['Ground', 'Comms'],
hints: [
'Reply with "Readability" plus the number.',
'Always end the check with your callsign.'
'Reply with "Readability" followed by the number.',
'Always finish the check with your call sign.'
],
fields: [
{
@@ -1291,7 +1255,7 @@ const modules = ref<ModuleDef[]>([
phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`,
info: scenario => [
`Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
`Expected answer: readability ${scenario.readability} (${scenario.readabilityWord})`
`Expected response: Readability ${scenario.readability} (${scenario.readabilityWord})`
],
generate: createBaseScenario
}
@@ -1300,16 +1264,16 @@ const modules = ref<ModuleDef[]>([
{
id: 'arc',
title: 'ARC Decision Tree',
subtitle: 'From the clearance call to departure',
subtitle: 'From clearance call to departure',
art: gradientArt(['#f97316', '#fb923c', '#0f172a']),
lessons: [
{
id: 'clearance-contact',
title: 'Delivery: Initial contact',
desc: 'Call clearance delivery',
desc: 'Contact clearance delivery',
keywords: ['Delivery', 'Clearance'],
hints: [
'Order: unit, callsign, ATIS, destination, stand, request.',
'Order: unit, call sign, ATIS, destination, stand, request.',
'Say the ATIS as a single letter.'
],
fields: [
@@ -1334,7 +1298,7 @@ const modules = ref<ModuleDef[]>([
},
{
key: 'cd-stand',
label: 'Stand',
label: 'Stand/Gate',
expected: scenario => scenario.stand,
width: 'sm'
}
@@ -1354,20 +1318,20 @@ const modules = ref<ModuleDef[]>([
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}`,
`ATIS: ${scenario.atisCode}`,
`Destination: ${scenario.destination.city} (${scenario.destination.icao})`,
`Stand: ${scenario.stand}`
`Stand/Gate: ${scenario.stand}`
],
generate: createBaseScenario
},
{
id: 'clearance-readback',
title: 'Clearance Readback',
desc: 'Read back the clearance completely',
desc: 'Read back the clearance in full',
keywords: ['Delivery', 'Readback'],
hints: [
'Remember the sequence: destination SID runway altitude squawk.',
'Spell out the altitude and squawk digits clearly.'
'Remember the order: destination SID runway altitude squawk.',
'Speak altitude and squawk digits clearly.'
],
fields: [
{
@@ -1429,11 +1393,11 @@ const modules = ref<ModuleDef[]>([
{
id: 'pushback',
title: 'Push & Start Readback',
desc: 'Confirm the pushback clearance',
desc: 'Acknowledge the pushback clearance',
keywords: ['Ground', 'Pushback'],
hints: [
'Repeat the runway direction and QNH, callsign at the end.',
'You may say QNH as a number or as "QNH xxxx".'
'Repeat the runway direction and QNH, call sign at the end.',
'QNH may be just the number or "QNH xxxx".'
],
fields: [
{
@@ -1473,7 +1437,7 @@ const modules = ref<ModuleDef[]>([
defaultFrequency: 'GND',
phrase: scenario => `${scenario.radioCall}, push and start approved, facing runway ${scenario.runway}. QNH ${scenario.qnh}.`,
info: scenario => [
`Stand: ${scenario.stand}`,
`Stand/Gate: ${scenario.stand}`,
`Ground: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`
],
generate: createBaseScenario
@@ -1481,11 +1445,11 @@ const modules = ref<ModuleDef[]>([
{
id: 'taxi',
title: 'Taxi Readback',
desc: 'Read back the taxi clearance with the hold short instruction',
desc: 'Read back the taxi clearance with hold short',
keywords: ['Ground', 'Taxi'],
hints: [
'Repeat the route as given, including the hold short.',
'Finish with the callsign again.'
'Repeat the route exactly as received, including the hold short.',
'Finish with the call sign.'
],
fields: [
{
@@ -1542,11 +1506,11 @@ const modules = ref<ModuleDef[]>([
{
id: 'lineup',
title: 'Line-up Clearance',
desc: 'Confirm line up and wait',
desc: 'Acknowledge line up and wait',
keywords: ['Tower', 'Line Up'],
hints: [
'Repeat the runway, then say "line up and wait".',
'Place the callsign at the end.'
'Place the call sign at the end.'
],
fields: [
{
@@ -1578,7 +1542,7 @@ const modules = ref<ModuleDef[]>([
phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`,
info: scenario => [
`Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
`Line up: runway ${scenario.runway}`
`Line-up: runway ${scenario.runway}`
],
generate: createBaseScenario
},
@@ -1588,8 +1552,8 @@ const modules = ref<ModuleDef[]>([
desc: 'Acknowledge the takeoff clearance',
keywords: ['Tower', 'Departure'],
hints: [
'Order: runway cleared for takeoff callsign.',
'Wind call can be omitted if not stated.'
'Order: runway cleared for takeoff call sign.',
'Wind information can be omitted if it was not given.'
],
fields: [
{
@@ -1631,13 +1595,13 @@ const modules = ref<ModuleDef[]>([
desc: 'Switch to departure',
keywords: ['Departure', 'Handoff'],
hints: [
'Repeat the frequency exactly, either as digits or spoken.',
'Add the callsign at the end.'
'Repeat the frequency exactly, either as digits or spoken form.',
'Append the call sign at the end.'
],
fields: [
{
key: 'dep-freq',
label: 'Frequency',
label: 'Frequenz',
expected: scenario => scenario.departureFreq,
alternatives: scenario => [
scenario.departureFreq,
@@ -1675,11 +1639,11 @@ const modules = ref<ModuleDef[]>([
{
id: 'departure-checkin',
title: 'Departure Check-in',
desc: 'First call with departure',
desc: 'Initial call to departure',
keywords: ['Departure', 'Check-in'],
hints: [
'Address the unit, then state the callsign.',
'Repeat the altitude and SID exactly as given.'
'Address the unit first, then state the call sign.',
'Repeat the altitude and SID exactly as received.'
],
fields: [
{
@@ -1730,11 +1694,11 @@ const modules = ref<ModuleDef[]>([
{
id: 'climb-instruction',
title: 'Climb & Direct',
desc: 'Read back the climb instruction completely',
desc: 'Read back the climb instruction in full',
keywords: ['Departure', 'Climb'],
hints: [
'Start with "Climb", then the altitude and any direct.',
'Repeat the callsign at the end.'
'Start with "Climb", then the altitude and any direct clearance.',
'Repeat the call sign at the end.'
],
fields: [
{
@@ -1788,13 +1752,13 @@ const modules = ref<ModuleDef[]>([
desc: 'Acknowledge the handoff to center',
keywords: ['Center', 'Handoff'],
hints: [
'Repeat the frequency exactly (with or without the decimal).',
'Place the callsign at the end.'
'Repeat the frequency exactly (with or without the decimal point).',
'Place the call sign at the end.'
],
fields: [
{
key: 'ctr-freq',
label: 'Frequency',
label: 'Frequenz',
expected: scenario => scenario.centerFreq,
alternatives: scenario => [
scenario.centerFreq,
@@ -1970,7 +1934,6 @@ if (isClient) {
resetAudioReveal()
})
watch(() => cfg.value.radioLevel, markConfigDirty)
watch(() => cfg.value.audioSpeed, markConfigDirty)
watch(() => cfg.value.voice, markConfigDirty)
}
@@ -2040,7 +2003,7 @@ function blankStateClass(key: string): string {
}
function fieldLabel(key: string): string {
return fieldMap.value[key]?.label ?? 'Feld'
return fieldMap.value[key]?.label ?? 'Field'
}
function fieldPlaceholder(key: string): string {
@@ -2066,12 +2029,6 @@ function fieldExpectedValue(key: string): string {
const targetPhrase = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.phrase(scenario.value) : ''))
const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : []))
const nextLesson = computed(() => {
if (!current.value || !activeLesson.value) return null
const lessons = current.value.lessons
const index = lessons.findIndex(lesson => lesson.id === activeLesson.value?.id)
return index >= 0 && index < lessons.length - 1 ? lessons[index + 1] : null
})
watch(activeLesson, lesson => {
if (lesson) {
@@ -2201,18 +2158,6 @@ function selectLesson(lesson: Lesson) {
activeLesson.value = lesson
}
function goToNextLesson() {
stopAudio()
if (nextLesson.value) {
activeLesson.value = nextLesson.value
return
}
panel.value = 'hub'
activeLesson.value = null
current.value = null
result.value = null
}
function bestScore(modId: string, lesId: string) {
return progress.value[modId]?.[lesId]?.best || 0
}
@@ -2241,8 +2186,8 @@ function avgScore(modId: string) {
const dailies = ref([
{ id: 'd1', title: '3 readbacks ≥80%', sub: 'Reward: +50 XP', reward: 50 },
{ id: 'd2', title: 'Start one module', sub: 'Reward: +20 XP', reward: 20 },
{ id: 'd3', title: 'Play a target phrase', sub: 'Reward: +10 XP', reward: 10 }
{ id: 'd2', title: 'Start 1 module', sub: 'Reward: +20 XP', reward: 20 },
{ id: 'd3', title: 'Play the target phrase', sub: 'Reward: +10 XP', reward: 10 }
])
function startDaily(daily: { id: string; reward: number; title: string }) {
@@ -2335,8 +2280,7 @@ async function say(text: string) {
if (!trimmed) return
const rateBase = 0.9 + (cfg.value.radioLevel - 3) * 0.12
const rateAdjusted = rateBase * (cfg.value.audioSpeed || 1)
const normalizedRate = Math.min(1.6, Math.max(0.6, rateAdjusted))
const normalizedRate = Math.min(1.5, Math.max(0.6, rateBase))
const hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window

View File

@@ -21,7 +21,7 @@
<v-card class="bg-white/5 backdrop-blur border border-white/10">
<v-card-text class="space-y-4">
<div>
<h2 class="text-lg font-semibold">VATSIM integration</h2>
<h2 class="text-lg font-semibold">VATSIM Integration</h2>
<p class="text-sm text-white/70">Enter your CID to load your filtered flight plans.</p>
</div>
<v-text-field
@@ -59,8 +59,8 @@
<v-card class="bg-white/5 backdrop-blur border border-white/10">
<v-card-text class="space-y-4">
<div>
<h2 class="text-lg font-semibold">Demo mode</h2>
<p class="text-sm text-white/70">Start with a sample flight plan to test the workflow.</p>
<h2 class="text-lg font-semibold">Demo Mode</h2>
<p class="text-sm text-white/70">Start with a sample flight plan for testing.</p>
</div>
<v-btn
block
@@ -203,7 +203,7 @@
@click="backToSetup"
prepend-icon="mdi-airplane-off"
>
Choose a different flight
Select new flight
</v-btn>
</v-card-text>
</v-card>
@@ -285,7 +285,7 @@
<div v-if="currentStep.pilot" class="space-y-2 rounded-2xl bg-blue-500/10 border border-blue-500/20 p-3 text-sm">
<div class="flex items-center gap-2 text-blue-300">
<v-icon size="16">mdi-account-pilot</v-icon>
<span class="text-xs uppercase font-semibold">Pilot (you)</span>
<span class="text-xs uppercase font-semibold">Pilot (You)</span>
</div>
<p class="font-mono text-white">{{ normalizeExpectedText(currentStep.pilot) }}</p>
</div>
@@ -396,7 +396,7 @@
<v-card class="bg-white/5 border border-white/10">
<v-card-text class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-lg font-semibold">Frequency directory</h3>
<h3 class="text-lg font-semibold">Frequency overview</h3>
<div class="flex items-center gap-2">
<v-progress-circular
v-if="airportFrequencyLoading"
@@ -421,7 +421,7 @@
<v-expansion-panels variant="accordion" class="bg-transparent">
<v-expansion-panel>
<v-expansion-panel-title class="text-sm text-white/70">
Frequencies for {{ flightContext.dep || 'Departure' }}
Frequencies for {{ flightContext.dep || 'departure' }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
@@ -527,7 +527,7 @@
@click:append-inner="sendPilotText"
/>
<p class="text-xs text-white/50">
For emergencies when PTT fails or for testing.
For emergencies when PTT fails or for testing
</p>
</v-card-text>
</v-card>
@@ -564,7 +564,7 @@
v-if="simulationRunning && simulationTrace.length === 0"
class="text-sm text-white/60"
>
Simulation initializing
Simulation initializing...
</div>
<div
@@ -609,7 +609,7 @@
<span v-if="debugState?.frequencyName" class="ml-1 text-white/40">({{ debugState.frequencyName }})</span>
</p>
<p v-if="debugState?.sayPlain" class="text-xs text-white/70">
Auto: <span class="font-mono text-white">{{ debugState.sayPlain }}</span>
Auto (LLM): <span class="font-mono text-white">{{ debugState.sayPlain }}</span>
</p>
<p v-if="debugState?.sayNormalized" class="text-[11px] text-white/40">
Radio: {{ debugState.sayNormalized }}
@@ -617,7 +617,7 @@
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Next decisions</p>
<p class="text-xs uppercase tracking-[0.3em] text-white/40 mb-2">Upcoming decisions</p>
<div v-if="debugNextStates.length" class="space-y-2">
<div
v-for="state in debugNextStates"
@@ -702,7 +702,7 @@
</div>
<p v-if="log.length === 0" class="text-xs text-white/50 text-center py-4">
No transmissions logged yet.
No communications recorded yet.
</p>
</div>
</v-card-text>
@@ -752,7 +752,7 @@
<template #prepend>
<v-icon size="16">mdi-alert-circle-outline</v-icon>
</template>
{{ lastTransmissionFaulty ? 'Review issue' : 'Mark issue' }}
{{ lastTransmissionFaulty ? 'Edit issue' : 'Mark as faulty' }}
</v-btn>
<v-btn
v-if="lastTransmissionFaulty"
@@ -801,12 +801,11 @@
<v-card class="bg-[#0b101d] border border-white/10 text-white">
<v-card-title class="flex items-center gap-2 text-base font-semibold">
<v-icon icon="mdi-alert-circle-outline" color="#f87171" size="20" />
Flag transmission as faulty
Mark transmission as faulty
</v-card-title>
<v-card-text class="space-y-4 text-sm text-white/70">
<p>
Leave an optional short description so the dev team can trace the
transmission.
Optionally leave a short note so the dev team can follow up on the transmission.
</p>
<v-textarea
v-model="transmissionIssueNote"

View File

@@ -5,7 +5,7 @@ export type Lesson = { id: string; title: string; desc: string; target: string;
export type ModuleDef = { id: string; title: string; subtitle: string; art: string; lessons: Lesson[] }
const modules = ref<ModuleDef[]>([
// NEW: ICAO Kapitel
// New: ICAO chapter
{
id: 'icao',
title: 'ICAO Alphabet',
@@ -15,33 +15,33 @@ const modules = ref<ModuleDef[]>([
{
id: 'alpha',
title: 'Alphabet AM',
desc: 'Alpha bis Mike sprechen.',
desc: 'Say Alpha through Mike.',
target: 'Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo Lima Mike.',
hints: ['konstant sprechen', 'deutlich trennen'],
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 NZ',
desc: 'November bis Zulu.',
desc: 'November through Zulu.',
target: 'November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu.',
hints: ['X-ray mit hyphen', 'Juliett mit zwei t'],
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: 'Zahlen',
desc: 'ICAO-Nummernlesen.',
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: 'Callsign Buchstabieren',
desc: 'Beispiel-Callsign.',
title: 'Spell the call sign',
desc: 'Sample call sign.',
target: 'DLH one two three, Lufthansa one two three.',
hints: ['DLH → Lufthansa', 'Nummern ICAO'],
hints: ['DLH → Lufthansa', 'Use ICAO numbers'],
keywords: ['Lufthansa', 'DLH', 'one', 'two', 'three']
}
]
@@ -49,19 +49,19 @@ const modules = ref<ModuleDef[]>([
{
id: 'basics',
title: 'Basics',
subtitle: 'Callsign · Struktur · Zahlen',
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: 'Erster Call korrekt.',
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: 'Kurz bestätigen.',
id: 'readback', title: 'Short Readback', desc: 'Give a short acknowledgement.',
target: 'Lufthansa one two three, roger.',
hints: ['Callsign + roger/affirm'], keywords: ['roger', 'affirm']
}
@@ -79,7 +79,7 @@ const modules = ref<ModuleDef[]>([
hints: ['Taxi to runway • via • hold short'], keywords: ['taxi to runway', 'via', 'hold short']
},
{
id: 'handoff', title: 'Handoff', desc: 'Frequenzwechsel.',
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']
}
@@ -92,7 +92,7 @@ const modules = ref<ModuleDef[]>([
art: 'https://images.unsplash.com/photo-1494412685616-a5d310fbb07d?q=80&w=1600&auto=format&fit=crop',
lessons: [
{
id: 'lineup', title: 'Line up', desc: 'Aufrollen und warten.',
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']
}
@@ -105,7 +105,7 @@ const modules = ref<ModuleDef[]>([
art: 'https://images.unsplash.com/photo-1542089363-07b2d92aacc3?q=80&w=1600&auto=format&fit=crop',
lessons: [
{
id: 'vacate', title: 'Vacate', desc: 'Verlasse Bahn, melde frei.',
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']
}
@@ -118,7 +118,7 @@ const modules = ref<ModuleDef[]>([
art: 'https://images.unsplash.com/photo-1508264769638-658b34d79f6e?q=80&w=1600&auto=format&fit=crop',
lessons: [
{
id: 'checkin', title: 'IFR Check-in', desc: 'Erster Online-Call.',
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']
}

View File

@@ -1,8 +1,8 @@
// composables/communicationsEngine.ts
// communicationsEngine composable
import { ref, computed, readonly } from 'vue'
import atcDecisionTree from "../data/atcDecisionTree";
// --- DecisionTree-Types (aus ~/data/atcDecisionTree.json abgeleitet) ---
// --- DecisionTree types (derived from ~/data/atcDecisionTree.json) ---
type Role = 'pilot' | 'atc' | 'system'
type Phase =
| 'Preflight' | 'Clearance' | 'PushStart' | 'TaxiOut' | 'Departure'
@@ -31,8 +31,8 @@ interface DTState {
auto?: 'check_readback' | 'monitor' | 'end' | 'pop_stack_or_route_by_intent'
readback_required?: string[]
actions?: (DTActionSet | string)[]
frequency?: string // Für einfachere Freq-Zugriffe
frequencyName?: string // Name der Frequenz (DEL, GND, TWR, etc.)
frequency?: string // Shortcut for accessing frequencies
frequencyName?: string // Frequency label (DEL, GND, TWR, etc.)
}
interface DecisionTree {
@@ -47,8 +47,8 @@ interface DecisionTree {
emergency_active: boolean
current_unit: string
stack: string[]
off_schema_count: number // Zähler für Off-Schema Antworten
radio_checks_done: number // Zähler für Radio Checks
off_schema_count: number // Counter for off-schema responses
radio_checks_done: number // Counter for radio checks
}
policies: {
timeouts: {
@@ -67,7 +67,7 @@ interface DecisionTree {
states: Record<string, DTState>
}
// Flight Phases und Communication Steps für bessere Integration
// Flight phases and communication steps for better integration
export const FLIGHT_PHASES = [
{ id: 'clearance', name: 'Clearance Delivery', frequency: '121.900', action: 'Request IFR clearance' },
{ id: 'ground', name: 'Ground Control', frequency: '121.700', action: 'Request pushback and taxi' },
@@ -90,7 +90,7 @@ export const COMMUNICATION_STEPS = [
atc: '{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.',
pilotResponse: '{callsign} cleared {dest} via {sid}, runway {runway}, climb {initial_altitude_ft}, squawk {squawk}.'
}
// Weitere Steps können hier definiert werden
// Additional steps can be defined here
]
type FrequencyVariableKey = 'atis_freq'
@@ -101,7 +101,7 @@ type FrequencyVariableKey = 'atis_freq'
| 'approach_freq'
| 'handoff_freq'
// --- ATC Decision Tree laden ---
// --- Load ATC decision tree ---
export interface FlightContext {
callsign: string
aircraft: string
@@ -140,7 +140,7 @@ export interface EngineLog {
offSchema?: boolean
}
// NATO/ICAO Normalizer (gekürzt für bessere Performance)
// NATO/ICAO normalizer trimmed for better performance
const NATO_PHONETIC: Record<string, string> = {
A: 'Alpha', B: 'Bravo', C: 'Charlie', D: 'Delta', E: 'Echo', F: 'Foxtrot',
G: 'Golf', H: 'Hotel', I: 'India', J: 'Juliett', K: 'Kilo', L: 'Lima',
@@ -212,7 +212,7 @@ export default function useCommunicationsEngine() {
const currentStateId = ref<string>(tree.value.start_state)
const communicationLog = ref<EngineLog[]>([])
// Flight Context für bessere Integration mit pm_alt.vue Stil
// Flight context used for pm_alt.vue integration
const flightContext = ref<FlightContext>({
callsign: '',
aircraft: 'A320',
@@ -264,7 +264,7 @@ export default function useCommunicationsEngine() {
}
})
// Current Step für pm_alt.vue Integration
// Current step for pm_alt.vue integration
const currentStep = computed(() => {
const phase = flightContext.value.phase
const step = COMMUNICATION_STEPS.find(s => s.phase === phase)
@@ -280,7 +280,7 @@ export default function useCommunicationsEngine() {
})
function initializeFlight(fpl: any) {
// Variablen setzen
// Set variables
variables.value = {
...variables.value,
callsign: fpl.callsign || fpl.callsign,
@@ -313,7 +313,7 @@ export default function useCommunicationsEngine() {
time_now: new Date().toISOString()
}
// Flight Context aktualisieren
// Update flight context
Object.assign(flightContext.value, {
...variables.value,
phase: 'clearance'
@@ -364,11 +364,11 @@ export default function useCommunicationsEngine() {
}
function applyLLMDecision(decision: any) {
// Updates anwenden
// Apply updates
if (decision.updates) Object.assign(variables.value, decision.updates)
if (decision.flags) Object.assign(flags.value, decision.flags)
// Off-Schema und Radio Check Tracking
// Track off-schema responses and radio checks
if (decision.off_schema) {
flags.value.off_schema_count++
console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`)
@@ -378,7 +378,7 @@ export default function useCommunicationsEngine() {
console.log(`[Engine] Radio check #${flags.value.radio_checks_done}`)
}
// Controller Response
// Controller response
if (decision.controller_say_tpl) {
speak('atc', decision.controller_say_tpl, currentStateId.value, {
radioCheck: decision.radio_check,
@@ -386,7 +386,7 @@ export default function useCommunicationsEngine() {
})
}
// State Transition (nur wenn nicht Radio Check)
// State transition (only when not a radio check)
if (!decision.radio_check) {
moveTo(decision.next_state)
}
@@ -396,7 +396,7 @@ export default function useCommunicationsEngine() {
// Log pilot input
speak('pilot', transcript, currentStateId.value)
// Radio Check Detection (fallback falls LLM es nicht erkennt)
// Radio check detection (fallback if the LLM misses it)
const t = transcript.toLowerCase()
if (t.includes('radio check') || (t.includes('read') && t.includes('check'))) {
const callsign = variables.value.callsign || ''
@@ -417,7 +417,7 @@ export default function useCommunicationsEngine() {
return null
}
return null // LLM soll entscheiden
return null // Let the LLM decide
}
function processUserTransmission(transcript: string): string | null {
@@ -437,7 +437,7 @@ export default function useCommunicationsEngine() {
currentStateId.value = stateId
const s = currentState.value
// Actions ausführen
// Execute actions
for (const act of s.actions ?? []) {
if (typeof act === 'string') continue
if (act.if && !safeEvalBoolean(act.if)) continue
@@ -457,7 +457,7 @@ export default function useCommunicationsEngine() {
speak(s.role, s.say_tpl, s.id!)
}
// Phase Update für Flight Context
// Update flight context phase
updateFlightPhase(s.phase)
}
@@ -599,7 +599,7 @@ export default function useCommunicationsEngine() {
communicationLog: readonly(communicationLog),
clearCommunicationLog: () => { communicationLog.value = [] },
// pm_alt.vue Integration
// pm_alt.vue integration
flightContext: readonly(flightContext),
currentStep,