diff --git a/docs/plans/2026-02-20-learn-pfd-implementation.md b/docs/plans/2026-02-20-learn-pfd-implementation.md new file mode 100644 index 0000000..b7acc5a --- /dev/null +++ b/docs/plans/2026-02-20-learn-pfd-implementation.md @@ -0,0 +1,1387 @@ +# Learn PFD Medienstation — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build an interactive PFD learning station at `/flightlab/medienstationen/learn-pfd` where users progressively learn to read an Airbus PFD through hands-on interaction with a simulated FBW flight model, driven by WebSocket sidestick/throttle input. + +**Architecture:** Nuxt 4 page with SVG-based PFD instrument components, a Three.js 3D aircraft model, and a `useAirbusFBW()` composable for flight physics. Input comes via WebSocket (reusing existing `/api/flightlab/ws`). Phase-based stepper with TTS narration reuses existing `useFlightLabEngine` + `useFlightLabAudio` patterns. + +**Tech Stack:** Vue 3 SFC, SVG, Three.js (new dep), existing FlightLab composables (engine, audio, sync), WebSocket, Tailwind CSS. + +--- + +## Task 1: Install Three.js and Create Branch + +**Files:** +- Modify: `package.json` + +**Step 1: Create feature branch** + +```bash +git checkout -b feat/learn-pfd +``` + +**Step 2: Install Three.js** + +```bash +bun add three && bun add -d @types/three +``` + +**Step 3: Commit** + +```bash +git add package.json bun.lockb +git commit -m "chore: add three.js dependency for 3D aircraft model" +``` + +--- + +## Task 2: Extend Types for Learn-PFD Phases + +**Files:** +- Modify: `shared/data/flightlab/types.ts` + +Add the new phase interface extension that learn-pfd needs, alongside existing types. + +**Step 1: Add PFD-specific types to `shared/data/flightlab/types.ts`** + +Append after the existing `FlightLabScenario` interface (line ~89): + +```typescript +// --- Learn PFD Extensions --- + +export type PfdElement = 'attitude' | 'speedTape' | 'altitudeTape' | 'verticalSpeed' | 'heading' +export type PfdLayoutMode = 'model-focus' | 'split' | 'pfd-focus' + +export interface PfdInteractionGoal { + /** What to check: pitch angle, altitude, heading, speed, bank */ + parameter: 'pitch' | 'altitude' | 'heading' | 'speed' | 'bankAngle' | 'verticalSpeed' + /** Target value */ + target: number + /** Acceptable deviation (+/-) */ + tolerance: number + /** How long (ms) the user must hold the value within tolerance to pass */ + holdMs?: number +} + +export interface LearnPfdPhase extends FlightLabPhase { + /** Which PFD elements are visible in this phase */ + visibleElements: PfdElement[] + /** Layout mode controlling PFD vs 3D model sizing */ + layoutMode: PfdLayoutMode + /** Optional interaction goal — user must achieve this to auto-advance */ + interactionGoal?: PfdInteractionGoal + /** Timeout (ms) before showing hint if goal not reached. Default 15000 */ + goalTimeoutMs?: number + /** Hint spoken via TTS if user struggles */ + goalHint?: string +} + +export interface LearnPfdScenario { + id: string + title: string + description: string + icon: string + phases: LearnPfdPhase[] +} +``` + +**Step 2: Commit** + +```bash +git add shared/data/flightlab/types.ts +git commit -m "feat(types): add LearnPfdPhase and PFD element types" +``` + +--- + +## Task 3: Create FBW Physics Composable + +**Files:** +- Create: `shared/composables/flightlab/useAirbusFBW.ts` + +This is the core flight dynamics model. It takes sidestick + throttle input and outputs realistic flight state. + +**Step 1: Create `shared/composables/flightlab/useAirbusFBW.ts`** + +```typescript +// shared/composables/flightlab/useAirbusFBW.ts +import { ref, reactive, onBeforeUnmount } from 'vue' + +export interface StickInput { + pitch: number // -1 (full forward) to +1 (full back) + roll: number // -1 (full left) to +1 (full right) + throttle: number // 0 (idle) to 1 (TOGA) +} + +export interface FlightState { + pitch: number // degrees (positive = nose up) + bankAngle: number // degrees (positive = right bank) + heading: number // 0-360 + speed: number // knots IAS + altitude: number // feet + verticalSpeed: number // feet per minute + aoa: number // angle of attack degrees + throttlePercent: number // 0-100 + onGround: boolean +} + +// --- Airbus Normal Law Constants --- +const MAX_PITCH_UP = 30 // degrees (alpha-floor protection kicks in before) +const MAX_PITCH_DOWN = -15 // degrees +const MAX_BANK = 67 // hard limit +const BANK_NEUTRAL_LIMIT = 33 // bank auto-returns to this if stick neutral +const MAX_ROLL_RATE = 15 // degrees/sec at full stick deflection +const ROLL_RETURN_RATE = 5 // degrees/sec auto-return when bank > 33° and stick neutral + +// Pitch: stick commands load factor (G), which translates to pitch rate +const MAX_G_PULL = 2.5 +const MIN_G_PUSH = -1.0 +const NEUTRAL_G = 1.0 +const PITCH_RATE_PER_G_DELTA = 3.5 // degrees/sec per G deviation from target + +// Speed / thrust model (simplified) +const IDLE_THRUST = 2000 // lbs equivalent +const MAX_THRUST = 50000 // lbs equivalent (both engines TOGA) +const DRAG_COEFFICIENT = 0.03 +const MASS = 150000 // lbs (A320 typical) +const GRAVITY = 32.174 // ft/s² +const KT_TO_FPS = 1.68781 // knots to feet per second + +// Initial state +const INITIAL_SPEED = 220 // knots +const INITIAL_ALTITUDE = 5000 // feet +const INITIAL_HEADING = 360 + +export function useAirbusFBW() { + const input = reactive({ pitch: 0, roll: 0, throttle: 0.5 }) + + const state = reactive({ + pitch: 2, + bankAngle: 0, + heading: INITIAL_HEADING, + speed: INITIAL_SPEED, + altitude: INITIAL_ALTITUDE, + verticalSpeed: 0, + aoa: 2, + throttlePercent: 50, + onGround: false, + }) + + let animFrame: number | null = null + let lastTime: number | null = null + let running = false + + function updateInput(newInput: Partial) { + if (newInput.pitch !== undefined) input.pitch = clamp(newInput.pitch, -1, 1) + if (newInput.roll !== undefined) input.roll = clamp(newInput.roll, -1, 1) + if (newInput.throttle !== undefined) input.throttle = clamp(newInput.throttle, 0, 1) + } + + function reset() { + input.pitch = 0 + input.roll = 0 + input.throttle = 0.5 + state.pitch = 2 + state.bankAngle = 0 + state.heading = INITIAL_HEADING + state.speed = INITIAL_SPEED + state.altitude = INITIAL_ALTITUDE + state.verticalSpeed = 0 + state.aoa = 2 + state.throttlePercent = 50 + state.onGround = false + lastTime = null + } + + function tick(timestamp: number) { + if (!running) return + if (lastTime === null) { + lastTime = timestamp + animFrame = requestAnimationFrame(tick) + return + } + + const dt = Math.min((timestamp - lastTime) / 1000, 0.1) // seconds, capped at 100ms + lastTime = timestamp + + // --- Throttle --- + state.throttlePercent = input.throttle * 100 + const thrust = IDLE_THRUST + input.throttle * (MAX_THRUST - IDLE_THRUST) + + // --- Roll (Normal Law: stick = roll rate command) --- + const targetRollRate = input.roll * MAX_ROLL_RATE + const isStickNeutral = Math.abs(input.roll) < 0.05 + + if (isStickNeutral) { + // Auto-return if bank > neutral limit + if (Math.abs(state.bankAngle) > BANK_NEUTRAL_LIMIT) { + const returnDir = state.bankAngle > 0 ? -1 : 1 + state.bankAngle += returnDir * ROLL_RETURN_RATE * dt + // Don't overshoot the limit + if (returnDir > 0 && state.bankAngle < -BANK_NEUTRAL_LIMIT) state.bankAngle = -BANK_NEUTRAL_LIMIT + if (returnDir < 0 && state.bankAngle > BANK_NEUTRAL_LIMIT) state.bankAngle = BANK_NEUTRAL_LIMIT + } + // Otherwise hold current bank angle (Normal Law behavior) + } else { + state.bankAngle += targetRollRate * dt + } + state.bankAngle = clamp(state.bankAngle, -MAX_BANK, MAX_BANK) + + // --- Pitch (Normal Law: stick = load factor command) --- + // Map stick to target G + const targetG = input.pitch > 0 + ? NEUTRAL_G + input.pitch * (MAX_G_PULL - NEUTRAL_G) // pull: 1G → 2.5G + : NEUTRAL_G + input.pitch * (NEUTRAL_G - MIN_G_PUSH) // push: 1G → -1G + + // Current G based on pitch rate (simplified) + const speedFps = state.speed * KT_TO_FPS + const currentG = speedFps > 50 ? 1 + (state.pitch * Math.PI / 180) * speedFps / GRAVITY * 0.01 : 1 + + // Pitch rate from G delta + const gDelta = targetG - currentG + const pitchRate = gDelta * PITCH_RATE_PER_G_DELTA + state.pitch += pitchRate * dt + state.pitch = clamp(state.pitch, MAX_PITCH_DOWN, MAX_PITCH_UP) + + // --- Speed --- + // Simplified: thrust - drag = acceleration + const speedFps2 = state.speed * KT_TO_FPS + const drag = DRAG_COEFFICIENT * speedFps2 * speedFps2 + const climbPenalty = Math.sin(state.pitch * Math.PI / 180) * MASS * GRAVITY * 0.3 + const netForce = thrust - drag - climbPenalty + const acceleration = netForce / MASS // ft/s² + const speedDelta = (acceleration / KT_TO_FPS) * dt + state.speed += speedDelta + state.speed = clamp(state.speed, 80, 380) // Vmin to Vmo (simplified) + + // --- Vertical Speed --- + // VS = speed * sin(pitch) adjusted for bank + const bankFactor = Math.cos(state.bankAngle * Math.PI / 180) + state.verticalSpeed = speedFps2 * Math.sin(state.pitch * Math.PI / 180) * 60 * bankFactor // fpm + + // --- Altitude --- + state.altitude += (state.verticalSpeed / 60) * dt + state.altitude = Math.max(0, state.altitude) + state.onGround = state.altitude <= 0 + + // --- Heading --- + // Standard rate turn: bank angle → turn rate + if (speedFps2 > 50) { + const turnRate = (GRAVITY * Math.tan(state.bankAngle * Math.PI / 180)) / speedFps2 + const headingDelta = turnRate * (180 / Math.PI) * dt + state.heading = ((state.heading + headingDelta) % 360 + 360) % 360 + } + + // --- AoA (simplified) --- + state.aoa = state.pitch - (state.verticalSpeed > 0 ? 1 : -1) * Math.min(Math.abs(state.verticalSpeed) / 500, 5) + + animFrame = requestAnimationFrame(tick) + } + + function start() { + if (running) return + running = true + lastTime = null + animFrame = requestAnimationFrame(tick) + } + + function stop() { + running = false + if (animFrame !== null) { + cancelAnimationFrame(animFrame) + animFrame = null + } + } + + function cleanup() { + stop() + } + + onBeforeUnmount(() => cleanup()) + + return { + input, + state, + updateInput, + reset, + start, + stop, + cleanup, + } +} + +function clamp(val: number, min: number, max: number): number { + return Math.min(max, Math.max(min, val)) +} +``` + +**Step 2: Commit** + +```bash +git add shared/composables/flightlab/useAirbusFBW.ts +git commit -m "feat(fbw): add Airbus Normal Law FBW physics composable" +``` + +--- + +## Task 4: Add `stick-input` WebSocket Message Type + +**Files:** +- Modify: `server/api/flightlab/ws.ts` (add case in switch ~line 82) +- Modify: `shared/composables/flightlab/useFlightLabSync.ts` (add callback + handler) +- Modify: `shared/data/flightlab/types.ts` (add WS event type) + +**Step 1: Add to WS event union in `shared/data/flightlab/types.ts`** + +Add to the `FlightLabWSEvent` type union (line ~109): + +```typescript + | { type: 'stick-input'; data: { pitch: number; roll: number; throttle: number } } +``` + +**Step 2: Add server handler in `server/api/flightlab/ws.ts`** + +Add a new case in the `switch (data.type)` block (after `subscribe-telemetry` case, before closing `}`): + +```typescript + case 'stick-input': { + // Broadcast stick/throttle input to all peers in session (for PFD display) + const session = findSessionByPeer(peerId) + if (!session) return + broadcastToSession(session, { type: 'stick-input', data: data.data }, peerId) + break + } +``` + +**Step 3: Add client handler in `shared/composables/flightlab/useFlightLabSync.ts`** + +Add to the `callbacks` object (after `onError` line ~18): + +```typescript + onStickInput: [] as Array<(data: { pitch: number; roll: number; throttle: number }) => void>, +``` + +Add to the `handleMessage` switch (after the `error` case): + +```typescript + case 'stick-input': + callbacks.onStickInput.forEach(cb => cb(data.data)) + break +``` + +Add the registration function (after `onError` function): + +```typescript + function onStickInput(cb: (data: { pitch: number; roll: number; throttle: number }) => void) { callbacks.onStickInput.push(cb) } +``` + +Add `sendStickInput` helper: + +```typescript + function sendStickInput(data: { pitch: number; roll: number; throttle: number }) { + send({ type: 'stick-input', data }) + } +``` + +Add both to the return object: + +```typescript + onStickInput, + sendStickInput, +``` + +**Step 4: Commit** + +```bash +git add shared/data/flightlab/types.ts server/api/flightlab/ws.ts shared/composables/flightlab/useFlightLabSync.ts +git commit -m "feat(ws): add stick-input WebSocket message type for PFD input" +``` + +--- + +## Task 5: Create PFD SVG Components + +**Files:** +- Create: `app/components/flightlab/pfd/PfdAttitudeIndicator.vue` +- Create: `app/components/flightlab/pfd/PfdSpeedTape.vue` +- Create: `app/components/flightlab/pfd/PfdAltitudeTape.vue` +- Create: `app/components/flightlab/pfd/PfdVerticalSpeed.vue` +- Create: `app/components/flightlab/pfd/PfdHeadingIndicator.vue` +- Create: `app/components/flightlab/pfd/PfdContainer.vue` + +Each component gets the flight state as props. Build them as realistic Airbus PFD instruments. + +### 5a: PfdAttitudeIndicator.vue + +The artificial horizon — the central and largest instrument. Shows sky (blue) / ground (brown), pitch ladder markings, bank angle arc with pointer, and aircraft reference symbol. + +```vue + + + +``` + +### 5b: PfdSpeedTape.vue + +Scrolling speed tape on the left side. Shows current IAS with a scrolling numeric scale and a readout box. + +```vue + + + +``` + +### 5c: PfdAltitudeTape.vue + +Same pattern as speed tape but on the right, showing altitude in feet. + +```vue + + + +``` + +### 5d: PfdVerticalSpeed.vue + +VS indicator to the right of the altitude tape. A vertical scale with a moving band/needle. + +```vue + + + +``` + +### 5e: PfdHeadingIndicator.vue + +Horizontal heading band at the bottom. A scrolling compass tape. + +```vue + + + +``` + +### 5f: PfdContainer.vue + +Orchestration component — arranges all instruments in PFD layout, controls visibility per phase. + +```vue + + + + + +``` + +**Step: Commit** + +```bash +git add app/components/flightlab/pfd/ +git commit -m "feat(pfd): add SVG PFD instrument components (attitude, speed, alt, VS, heading)" +``` + +--- + +## Task 6: Create 3D Aircraft Model Component + +**Files:** +- Create: `app/components/flightlab/pfd/PfdAircraftModel.vue` + +Uses Three.js to render a simple 3D aircraft that reacts to flight state. Since we don't have a GLTF model file yet, start with a stylized geometric A320 shape built from Three.js primitives (box geometry fuselage + wings). This can be swapped for a GLTF later. + +```vue + + + +``` + +**Step: Commit** + +```bash +git add app/components/flightlab/pfd/PfdAircraftModel.vue +git commit -m "feat(3d): add Three.js aircraft model component for PFD learning" +``` + +--- + +## Task 7: Create Learn-PFD Scenario Data + +**Files:** +- Create: `shared/data/flightlab/learn-pfd.ts` + +Phase definitions following the design — progressive PFD element introduction with TTS messages, interaction goals, and layout modes. + +**Step 1: Create scenario data** + +Create `shared/data/flightlab/learn-pfd.ts` with the full phase tree. The phases should follow the structure from the design doc: welcome → horizon → pitch → speed tape → altitude tape → VS → heading → zusammenspiel → free practice. + +Each phase needs: +- `id`, `atcMessage` (TTS text in German), `explanation` +- `visibleElements: PfdElement[]` +- `layoutMode: PfdLayoutMode` +- `buttons` for navigation +- Optional `interactionGoal` for hands-on tasks +- Optional comfort/info branches + +Key points for the TTS messages: +- Simple German, casual tone (like existing takeoff scenario) +- Short sentences for attention span +- Immediately reference what the user should do with the stick +- Celebrate small wins + +This file will be ~300-400 lines. Use the same pattern as `takeoff-eddf.ts`. + +**Step 2: Commit** + +```bash +git add shared/data/flightlab/learn-pfd.ts +git commit -m "feat(data): add learn-pfd scenario phases with progressive PFD element introduction" +``` + +--- + +## Task 8: Create Learn-PFD Engine (Phase + Goal Evaluation) + +**Files:** +- Create: `shared/composables/flightlab/useLearnPfdEngine.ts` + +This composable wraps `useFlightLabEngine` logic but adds interaction goal evaluation against the FBW state instead of SimConnect telemetry. + +It needs to: +1. Manage phase navigation (reuse pattern from useFlightLabEngine) +2. Evaluate `interactionGoal` against current FBW `FlightState` +3. Track hold time (user must maintain target for `holdMs`) +4. Trigger hints after `goalTimeoutMs` +5. Auto-advance when goal met + +**Step 1: Create composable** + +The composable takes a `LearnPfdScenario` and a reactive `FlightState` reference. It checks goals every frame-ish (200ms interval is fine). + +**Step 2: Commit** + +```bash +git add shared/composables/flightlab/useLearnPfdEngine.ts +git commit -m "feat(engine): add learn-pfd engine with interaction goal evaluation" +``` + +--- + +## Task 9: Create Medienstationen Index Page + +**Files:** +- Create: `app/pages/flightlab/medienstationen/index.vue` + +Grid page listing available media stations. Same style as `/flightlab/index.vue` (dark bg, cards with icons and badges, Tailwind + Vuetify). + +First card: "PFD verstehen" linking to `/flightlab/medienstationen/learn-pfd`. +Second card: "Coming soon" placeholder. + +**Step 1: Create page** + +Follow the exact pattern from `/app/pages/flightlab/index.vue`: +- `definePageMeta({ layout: false, middleware: ['require-auth'] })` +- Same header structure (FlightLab branding, back link to `/flightlab`) +- Hero section adapted for Medienstationen +- Card grid + +**Step 2: Also add a link from `/flightlab/index.vue`** + +Add a "Medienstationen" section or card below the existing scenarios grid, linking to `/flightlab/medienstationen`. + +**Step 3: Commit** + +```bash +git add app/pages/flightlab/medienstationen/index.vue app/pages/flightlab/index.vue +git commit -m "feat(pages): add medienstationen index page with learn-pfd card" +``` + +--- + +## Task 10: Create Learn-PFD Main Page + +**Files:** +- Create: `app/pages/flightlab/medienstationen/learn-pfd.vue` + +This is the main experience page. It wires together: +- `useAirbusFBW()` for physics +- `useLearnPfdEngine()` for phase management +- `useFlightLabAudio()` for TTS +- `useFlightLabSync()` for WebSocket stick input +- `PfdContainer` + `PfdAircraftModel` for display +- Dynamic layout switching per phase +- Sidebar stepper (reuse pattern from takeoff.vue) +- Fullscreen support + +**Key behaviors:** +1. On mount: connect to WebSocket, register `onStickInput` callback → feeds `fbw.updateInput()` +2. Phase changes → trigger TTS, update visible elements, switch layout +3. Layout transitions: CSS Grid with `transition: grid-template-columns 0.6s ease` +4. Three layout modes: + - `model-focus`: `grid-template-columns: 1fr 2fr` (PFD small, 3D big) + - `split`: `grid-template-columns: 1fr 1fr` + - `pfd-focus`: `grid-template-columns: 2fr 1fr` (PFD big, 3D small) + +**Step 1: Create the page** + +Follow takeoff.vue patterns for: +- Page meta, head +- Sidebar stepper +- Header bar +- Fullscreen/escape handling +- TTS phase watcher +- onMounted/onBeforeUnmount cleanup + +Main content area is a CSS Grid with PFD on one side and 3D model on the other, with an instruction overlay at the bottom. + +**Step 2: Commit** + +```bash +git add app/pages/flightlab/medienstationen/learn-pfd.vue +git commit -m "feat(learn-pfd): add main learn-pfd page with dynamic layout and WebSocket input" +``` + +--- + +## Task 11: Integration Testing & Polish + +**Step 1: Run dev server** + +```bash +bun run dev +``` + +**Step 2: Manual testing checklist** + +- [ ] Navigate to `/flightlab/medienstationen` — see index with learn-pfd card +- [ ] Click card → navigate to `/flightlab/medienstationen/learn-pfd` +- [ ] Page loads fullscreen-capable with black screen (phase 1) +- [ ] TTS speaks welcome message +- [ ] Click "Weiter" → horizon appears with animation +- [ ] Open a second tab, connect via WebSocket, send stick input → verify PFD reacts +- [ ] Progress through all phases — each element fades in +- [ ] Layout transitions smoothly between modes +- [ ] 3D model reflects pitch/roll changes +- [ ] Stepper sidebar shows progress +- [ ] Interaction goals auto-advance when met + +**Step 3: Fix any issues found** + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat(learn-pfd): polish and integration fixes" +``` + +--- + +## Task Dependencies + +``` +Task 1 (deps) + → Task 2 (types) + → Task 3 (FBW) — independent of Task 2 + → Task 4 (WS) — depends on Task 2 for types + → Task 5 (PFD components) — independent + → Task 6 (3D model) — depends on Task 1 (Three.js) + → Task 7 (scenario data) — depends on Task 2 (types) + → Task 8 (engine) — depends on Task 2, Task 3 + → Task 9 (index page) — independent + → Task 10 (main page) — depends on ALL above + → Task 11 (testing) — depends on Task 10 +``` + +**Parallelizable after Task 1+2:** Tasks 3, 4, 5, 6, 7, 9 can all be done in parallel.