mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-13 01:46:08 +08:00
first version looks ok
This commit is contained in:
638
app/pages/copilot.vue
Normal file
638
app/pages/copilot.vue
Normal file
@@ -0,0 +1,638 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {a320Profile, scratchFields, sopProfiles, type SopPhase, type SopStep} from '~~/shared/data/a320SopTimeline'
|
||||
|
||||
useHead({
|
||||
title: 'Copilot · A320 SOP – OpenSquawk',
|
||||
meta: [
|
||||
{name: 'apple-mobile-web-app-capable', content: 'yes'},
|
||||
{name: 'mobile-web-app-capable', content: 'yes'},
|
||||
{name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent'},
|
||||
{name: 'theme-color', content: '#050910'},
|
||||
{name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no'},
|
||||
],
|
||||
link: [{rel: 'manifest', href: '/copilot.webmanifest'}],
|
||||
})
|
||||
|
||||
const STORAGE_KEY = 'opensquawk.copilot.v1'
|
||||
|
||||
// Statische Tailwind-Klassen pro Phase (dynamic interp wird nicht von JIT erkannt)
|
||||
const phaseChrome: Record<string, { bar: string; label: string }> = {
|
||||
cyan: {bar: 'bg-cyan-500/10 border-cyan-400/20', label: 'text-cyan-300'},
|
||||
sky: {bar: 'bg-sky-500/10 border-sky-400/20', label: 'text-sky-300'},
|
||||
indigo: {bar: 'bg-indigo-500/10 border-indigo-400/20', label: 'text-indigo-300'},
|
||||
emerald: {bar: 'bg-emerald-500/10 border-emerald-400/20', label: 'text-emerald-300'},
|
||||
amber: {bar: 'bg-amber-500/10 border-amber-400/20', label: 'text-amber-300'},
|
||||
rose: {bar: 'bg-rose-500/10 border-rose-400/20', label: 'text-rose-300'},
|
||||
}
|
||||
|
||||
const activeProfile = ref(a320Profile)
|
||||
const phases = computed<SopPhase[]>(() => activeProfile.value.phases)
|
||||
|
||||
// Scratchpad-State
|
||||
const scratch = ref<Record<string, string>>({})
|
||||
const variantSel = ref<Record<string, string>>({}) // stepId → variantId
|
||||
const completed = ref<Record<string, boolean>>({})
|
||||
const activeStepId = ref<string | null>(null)
|
||||
const simbriefUser = ref('')
|
||||
const simbriefLoading = ref(false)
|
||||
const simbriefError = ref('')
|
||||
|
||||
// SimBrief import
|
||||
async function importSimbrief() {
|
||||
if (!simbriefUser.value.trim()) return
|
||||
simbriefLoading.value = true
|
||||
simbriefError.value = ''
|
||||
try {
|
||||
const value = simbriefUser.value.trim()
|
||||
const isNumeric = /^\d+$/.test(value)
|
||||
const params = isNumeric ? {userid: value} : {username: value}
|
||||
const data = await $fetch<any>('/api/copilot/simbrief', {params})
|
||||
const map: Record<string, any> = {
|
||||
callsign: data.callsign,
|
||||
flightNumber: data.flightNumber,
|
||||
aircraftReg: data.aircraftReg,
|
||||
departure: data.departure,
|
||||
destination: data.destination,
|
||||
altn: data.altn,
|
||||
route: data.route,
|
||||
crzFL: data.crzFL,
|
||||
costIndex: data.costIndex,
|
||||
zfw: data.zfw,
|
||||
blockFuel: data.blockFuel,
|
||||
tripFuel: data.tripFuel,
|
||||
sid: data.sid,
|
||||
rwy: data.rwy,
|
||||
transAlt: data.transAlt,
|
||||
transLevel: data.transLevel,
|
||||
initialClimb: data.atc?.initial_alt,
|
||||
}
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (v !== undefined && v !== null && v !== '') scratch.value[k] = String(v)
|
||||
}
|
||||
persist()
|
||||
} catch (e: any) {
|
||||
simbriefError.value = e?.data?.statusMessage || e?.message || 'Fehler beim Laden'
|
||||
} finally {
|
||||
simbriefLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Template-Interpolation für Callouts
|
||||
function interp(t?: string): string {
|
||||
if (!t) return ''
|
||||
return t.replace(/\[([^\]]+)\]/g, (_, key) => {
|
||||
const k = String(key).trim()
|
||||
const map: Record<string, string> = {
|
||||
'Callsign': scratch.value.callsign,
|
||||
'CALLSIGN': scratch.value.callsign,
|
||||
'DEST': scratch.value.destination,
|
||||
'SID': scratch.value.sid,
|
||||
'RWY': scratch.value.rwy,
|
||||
'ALT': scratch.value.initialClimb,
|
||||
'CLEARED ALT': scratch.value.initialClimb,
|
||||
'SQK': scratch.value.squawk,
|
||||
'ATIS': scratch.value.atisLetter,
|
||||
'XX': scratch.value.stand,
|
||||
'FREQ': scratch.value.depFreq,
|
||||
'TEMP': scratch.value.flexTemp,
|
||||
'TWY': scratch.value.taxiRoute,
|
||||
'TWY A, B, C': scratch.value.taxiRoute,
|
||||
}
|
||||
const val = map[k]
|
||||
return val ? val : `[${k}]`
|
||||
})
|
||||
}
|
||||
|
||||
// Visible steps (mit Variant-Substitution)
|
||||
function stepsForPhase(phase: SopPhase): SopStep[] {
|
||||
const out: SopStep[] = []
|
||||
for (const s of phase.steps) {
|
||||
if (s.variants?.length) {
|
||||
const sel = variantSel.value[s.id] || s.variants[0].id
|
||||
out.push(s)
|
||||
const variant = s.variants.find(v => v.id === sel)
|
||||
if (variant) out.push(...variant.steps)
|
||||
} else {
|
||||
out.push(s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const allSteps = computed(() => {
|
||||
const out: { phase: SopPhase; step: SopStep }[] = []
|
||||
for (const p of phases.value) for (const s of stepsForPhase(p)) out.push({phase: p, step: s})
|
||||
return out
|
||||
})
|
||||
|
||||
const progressPct = computed(() => {
|
||||
const total = allSteps.value.length
|
||||
if (!total) return 0
|
||||
const done = allSteps.value.filter(({step}) => completed.value[step.id]).length
|
||||
return Math.round((done / total) * 100)
|
||||
})
|
||||
|
||||
function toggleDone(id: string) {
|
||||
completed.value[id] = !completed.value[id]
|
||||
persist()
|
||||
}
|
||||
|
||||
function setActive(id: string) {
|
||||
activeStepId.value = id
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
const list = allSteps.value
|
||||
const idx = list.findIndex(({step}) => step.id === activeStepId.value)
|
||||
const cur = list[idx]
|
||||
if (cur) completed.value[cur.step.id] = true
|
||||
const next = list[idx + 1]
|
||||
if (next) {
|
||||
activeStepId.value = next.step.id
|
||||
nextTick(() => scrollToStep(next.step.id))
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
function scrollToStep(id: string) {
|
||||
const el = document.querySelector(`[data-step-id="${id}"]`) as HTMLElement | null
|
||||
if (el) el.scrollIntoView({behavior: 'smooth', block: 'center'})
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (!confirm('Alle Eingaben & Fortschritt löschen?')) return
|
||||
scratch.value = {}
|
||||
completed.value = {}
|
||||
variantSel.value = {}
|
||||
activeStepId.value = null
|
||||
persist()
|
||||
}
|
||||
|
||||
// Persistence
|
||||
function persist() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
scratch: scratch.value,
|
||||
completed: completed.value,
|
||||
variantSel: variantSel.value,
|
||||
activeStepId: activeStepId.value,
|
||||
}))
|
||||
}
|
||||
|
||||
watch([scratch, variantSel], persist, {deep: true})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const v = JSON.parse(raw)
|
||||
if (v.scratch) scratch.value = v.scratch
|
||||
if (v.completed) completed.value = v.completed
|
||||
if (v.variantSel) variantSel.value = v.variantSel
|
||||
if (v.activeStepId) activeStepId.value = v.activeStepId
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
setupCanvas()
|
||||
setupActiveObserver()
|
||||
if (!activeStepId.value && allSteps.value.length) activeStepId.value = allSteps.value[0].step.id
|
||||
})
|
||||
|
||||
// Aktive Step-Beobachtung beim Scrollen
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
function setupActiveObserver() {
|
||||
if (typeof IntersectionObserver === 'undefined') return
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting && e.intersectionRatio > 0.6) {
|
||||
const id = (e.target as HTMLElement).dataset.stepId
|
||||
if (id) activeStepId.value = id
|
||||
}
|
||||
}
|
||||
}, {threshold: [0.6]})
|
||||
nextTick(() => {
|
||||
document.querySelectorAll('[data-step-id]').forEach(el => observer?.observe(el))
|
||||
})
|
||||
}
|
||||
|
||||
watch(allSteps, () => {
|
||||
nextTick(() => {
|
||||
observer?.disconnect()
|
||||
document.querySelectorAll('[data-step-id]').forEach(el => observer?.observe(el))
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
|
||||
// Right Panel Tabs
|
||||
const rightTab = ref<'scratch' | 'canvas'>('scratch')
|
||||
|
||||
const groupedFields = computed(() => {
|
||||
const groups: Record<string, typeof scratchFields> = {}
|
||||
for (const f of scratchFields) {
|
||||
if (!groups[f.group]) groups[f.group] = []
|
||||
groups[f.group].push(f)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const groupTitles: Record<string, string> = {
|
||||
flight: 'Flight',
|
||||
env: 'ATIS / Environment',
|
||||
atc: 'ATC',
|
||||
perf: 'Performance',
|
||||
fuel: 'Fuel & Weights',
|
||||
}
|
||||
|
||||
// Canvas (Apple Pencil)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const drawColor = ref('#fde68a')
|
||||
const lineWidth = ref(2.4)
|
||||
|
||||
function setupCanvas() {
|
||||
const cv = canvasRef.value
|
||||
if (!cv) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const resize = () => {
|
||||
const rect = cv.getBoundingClientRect()
|
||||
cv.width = rect.width * dpr
|
||||
cv.height = rect.height * dpr
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}
|
||||
resize()
|
||||
new ResizeObserver(resize).observe(cv)
|
||||
|
||||
let drawing = false
|
||||
let last: { x: number; y: number; p: number } | null = null
|
||||
|
||||
function pos(e: PointerEvent) {
|
||||
const r = cv!.getBoundingClientRect()
|
||||
return {x: e.clientX - r.left, y: e.clientY - r.top, p: e.pressure || 0.5}
|
||||
}
|
||||
|
||||
cv.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType === 'touch' && (e as any).isPrimary === false) return
|
||||
cv.setPointerCapture(e.pointerId)
|
||||
drawing = true
|
||||
last = pos(e)
|
||||
})
|
||||
cv.addEventListener('pointermove', (e) => {
|
||||
if (!drawing || !last) return
|
||||
const ctx = cv.getContext('2d')!
|
||||
const cur = pos(e)
|
||||
ctx.strokeStyle = drawColor.value
|
||||
ctx.lineWidth = lineWidth.value * (0.5 + cur.p)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(last.x, last.y)
|
||||
ctx.lineTo(cur.x, cur.y)
|
||||
ctx.stroke()
|
||||
last = cur
|
||||
})
|
||||
const stop = () => {
|
||||
drawing = false
|
||||
last = null
|
||||
}
|
||||
cv.addEventListener('pointerup', stop)
|
||||
cv.addEventListener('pointercancel', stop)
|
||||
cv.addEventListener('pointerleave', stop)
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
const cv = canvasRef.value
|
||||
if (!cv) return
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.clearRect(0, 0, cv.width, cv.height)
|
||||
}
|
||||
|
||||
const actorMeta: Record<string, { label: string; cls: string }> = {
|
||||
pilot: {label: 'PILOT → ATC', cls: 'bg-cyan-500/15 text-cyan-200 border-cyan-400/30'},
|
||||
pf: {label: 'PF', cls: 'bg-emerald-500/15 text-emerald-200 border-emerald-400/30'},
|
||||
pm: {label: 'PM', cls: 'bg-violet-500/15 text-violet-200 border-violet-400/30'},
|
||||
atc: {label: 'ATC', cls: 'bg-amber-500/15 text-amber-200 border-amber-400/30'},
|
||||
cabin: {label: 'CABIN', cls: 'bg-rose-500/15 text-rose-200 border-rose-400/30'},
|
||||
system: {label: 'SYSTEM', cls: 'bg-white/10 text-white/80 border-white/15'},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="copilot-root min-h-screen bg-[#050910] text-white">
|
||||
<!-- Top Bar -->
|
||||
<header class="sticky top-0 z-30 border-b border-white/10 bg-[#050910]/85 backdrop-blur">
|
||||
<div class="mx-auto flex w-full max-w-[1600px] flex-wrap items-center gap-2 px-3 py-2 sm:px-5 sm:py-3">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 font-semibold tracking-tight">
|
||||
<v-icon icon="mdi-airplane-takeoff" size="22" class="text-cyan-400"/>
|
||||
<span class="text-sm sm:text-base">Copilot</span>
|
||||
</NuxtLink>
|
||||
<span class="hidden sm:inline rounded-full border border-cyan-400/30 bg-cyan-400/10 px-2 py-0.5 text-[11px] uppercase tracking-wider text-cyan-300">
|
||||
{{ activeProfile.name }}
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<div class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2 py-1">
|
||||
<input
|
||||
v-model="simbriefUser"
|
||||
placeholder="SimBrief User / ID"
|
||||
class="w-32 sm:w-44 bg-transparent text-sm placeholder-white/40 focus:outline-none"
|
||||
@keyup.enter="importSimbrief"
|
||||
/>
|
||||
<button
|
||||
class="rounded-md bg-cyan-500/90 px-2.5 py-1 text-xs font-medium text-black hover:bg-cyan-400 disabled:opacity-50"
|
||||
:disabled="simbriefLoading || !simbriefUser.trim()"
|
||||
@click="importSimbrief"
|
||||
>
|
||||
<v-icon v-if="simbriefLoading" icon="mdi-loading mdi-spin" size="14"/>
|
||||
<span v-else>Import</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:bg-white/10" @click="resetAll">
|
||||
<v-icon icon="mdi-refresh" size="14" class="mr-1"/>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-1 w-full bg-white/5">
|
||||
<div class="h-full bg-gradient-to-r from-cyan-400 via-sky-400 to-violet-400 transition-all" :style="{width: progressPct + '%'}"/>
|
||||
</div>
|
||||
<div v-if="simbriefError" class="bg-rose-500/15 px-4 py-1 text-xs text-rose-200">
|
||||
{{ simbriefError }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Grid -->
|
||||
<main class="mx-auto grid w-full max-w-[1600px] gap-3 px-2 py-3 sm:px-4 sm:py-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,460px)]">
|
||||
<!-- Timeline Column -->
|
||||
<section class="relative">
|
||||
<div
|
||||
class="timeline-scroll snap-y snap-mandatory overflow-y-auto rounded-2xl border border-white/10 bg-white/[0.02] shadow-inner"
|
||||
style="max-height: calc(100dvh - 180px); min-height: 60vh;"
|
||||
>
|
||||
<div
|
||||
v-for="phase in phases"
|
||||
:key="phase.id"
|
||||
class="snap-start scroll-mt-2"
|
||||
>
|
||||
<div :class="['sticky top-0 z-10 border-b px-4 py-2.5 backdrop-blur', phaseChrome[phase.color]?.bar || 'bg-white/5 border-white/10']">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div :class="['text-[11px] font-semibold uppercase tracking-[0.18em]', phaseChrome[phase.color]?.label || 'text-white/70']">
|
||||
Phase
|
||||
</div>
|
||||
<h2 class="text-base font-semibold sm:text-lg">{{ phase.title }}</h2>
|
||||
<div v-if="phase.subtitle" class="text-xs text-white/55">{{ phase.subtitle }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-white/50">
|
||||
{{ stepsForPhase(phase).filter(s => completed[s.id]).length }} / {{ stepsForPhase(phase).length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="space-y-2.5 px-3 py-3 sm:px-5 sm:py-4">
|
||||
<template v-for="step in phase.steps" :key="step.id">
|
||||
<!-- Step ohne Variants -->
|
||||
<li
|
||||
v-if="!step.variants"
|
||||
:data-step-id="step.id"
|
||||
class="snap-start scroll-mt-20"
|
||||
@click="setActive(step.id)"
|
||||
>
|
||||
<div :class="['group rounded-xl border bg-white/[0.03] p-3 transition sm:p-4',
|
||||
activeStepId === step.id ? 'border-cyan-400/60 ring-1 ring-cyan-400/40' : 'border-white/10 hover:border-white/20',
|
||||
completed[step.id] ? 'opacity-60' : '']">
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-white/20"
|
||||
:class="completed[step.id] ? 'bg-emerald-500/80 border-emerald-400 text-black' : 'bg-white/5'"
|
||||
@click.stop="toggleDone(step.id)"
|
||||
>
|
||||
<v-icon v-if="completed[step.id]" icon="mdi-check" size="14"/>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span :class="['rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider', actorMeta[step.actor]?.cls]">
|
||||
{{ actorMeta[step.actor]?.label || step.actor }}
|
||||
</span>
|
||||
<span class="text-sm font-medium sm:text-base">{{ step.label }}</span>
|
||||
</div>
|
||||
<p v-if="step.callout" class="mt-2 rounded-lg bg-black/40 px-3 py-2 font-mono text-[13px] leading-relaxed text-cyan-100/90 ring-1 ring-cyan-400/15">
|
||||
{{ interp(step.callout) }}
|
||||
</p>
|
||||
<p v-if="step.detail" class="mt-2 text-sm text-white/70">{{ step.detail }}</p>
|
||||
<div v-if="step.look" class="mt-1.5 inline-flex items-center gap-1 text-xs text-white/50">
|
||||
<v-icon icon="mdi-eye-outline" size="13"/>
|
||||
{{ step.look }}
|
||||
</div>
|
||||
<div v-if="step.inputKeys?.length" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="k in step.inputKeys"
|
||||
:key="k"
|
||||
:class="['rounded-md border px-2 py-0.5 text-[11px]',
|
||||
scratch[k] ? 'border-cyan-400/40 bg-cyan-400/10 text-cyan-200' : 'border-white/15 bg-white/5 text-white/55']"
|
||||
>
|
||||
{{ scratchFields.find(f => f.key === k)?.label || k }}{{ scratch[k] ? `: ${scratch[k]}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step mit Variants: horizontaler Slider -->
|
||||
<li
|
||||
v-else
|
||||
:data-step-id="step.id"
|
||||
class="snap-start scroll-mt-20"
|
||||
@click="setActive(step.id)"
|
||||
>
|
||||
<div :class="['rounded-xl border bg-white/[0.03] p-3 sm:p-4',
|
||||
activeStepId === step.id ? 'border-cyan-400/60 ring-1 ring-cyan-400/40' : 'border-white/10']">
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-white/20"
|
||||
:class="completed[step.id] ? 'bg-emerald-500/80 border-emerald-400 text-black' : 'bg-white/5'"
|
||||
@click.stop="toggleDone(step.id)"
|
||||
>
|
||||
<v-icon v-if="completed[step.id]" icon="mdi-check" size="14"/>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span :class="['rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider', actorMeta[step.actor]?.cls]">
|
||||
{{ actorMeta[step.actor]?.label }}
|
||||
</span>
|
||||
<span class="text-sm font-medium sm:text-base">{{ step.label }}</span>
|
||||
</div>
|
||||
<p v-if="step.detail" class="mt-1.5 text-sm text-white/70">{{ step.detail }}</p>
|
||||
|
||||
<!-- Variant Tabs -->
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="v in step.variants"
|
||||
:key="v.id"
|
||||
:class="['rounded-md px-2.5 py-1 text-xs transition',
|
||||
(variantSel[step.id] || step.variants![0].id) === v.id
|
||||
? 'bg-cyan-500/80 text-black'
|
||||
: 'bg-white/5 text-white/70 hover:bg-white/10']"
|
||||
@click.stop="variantSel[step.id] = v.id"
|
||||
>
|
||||
{{ v.title }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Variant-Steps -->
|
||||
<div class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="vstep in (step.variants.find(v => v.id === (variantSel[step.id] || step.variants![0].id))?.steps || [])"
|
||||
:key="vstep.id"
|
||||
:data-step-id="vstep.id"
|
||||
class="rounded-lg border border-white/10 bg-black/30 p-2.5 sm:p-3"
|
||||
:class="[completed[vstep.id] ? 'opacity-60' : '']"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border border-white/20"
|
||||
:class="completed[vstep.id] ? 'bg-emerald-500/80 border-emerald-400 text-black' : 'bg-white/5'"
|
||||
@click.stop="toggleDone(vstep.id)"
|
||||
>
|
||||
<v-icon v-if="completed[vstep.id]" icon="mdi-check" size="11"/>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ vstep.label }}</div>
|
||||
<p v-if="vstep.callout" class="mt-1.5 rounded bg-black/40 px-2 py-1.5 font-mono text-xs text-cyan-100/90 ring-1 ring-cyan-400/15">
|
||||
{{ interp(vstep.callout) }}
|
||||
</p>
|
||||
<p v-if="vstep.detail" class="mt-1 text-xs text-white/65">{{ vstep.detail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Action Bar -->
|
||||
<div class="mt-2 flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div class="text-xs text-white/60">
|
||||
Aktiv: <span class="text-white/90">{{ allSteps.find(s => s.step.id === activeStepId)?.step.label || '–' }}</span>
|
||||
</div>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<button class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
||||
@click="activeStepId && toggleDone(activeStepId)">
|
||||
<v-icon icon="mdi-check-circle-outline" size="14" class="mr-1"/>
|
||||
Mark done
|
||||
</button>
|
||||
<button class="rounded-md bg-cyan-500/90 px-4 py-1.5 text-xs font-semibold text-black hover:bg-cyan-400" @click="nextStep">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<aside class="space-y-3">
|
||||
<div class="flex gap-1 rounded-xl border border-white/10 bg-white/[0.03] p-1">
|
||||
<button
|
||||
:class="['flex-1 rounded-lg px-3 py-1.5 text-sm transition',
|
||||
rightTab === 'scratch' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/90']"
|
||||
@click="rightTab = 'scratch'"
|
||||
>
|
||||
<v-icon icon="mdi-notebook-edit-outline" size="16" class="mr-1"/>
|
||||
Scratchpad
|
||||
</button>
|
||||
<button
|
||||
:class="['flex-1 rounded-lg px-3 py-1.5 text-sm transition',
|
||||
rightTab === 'canvas' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/90']"
|
||||
@click="rightTab = 'canvas'"
|
||||
>
|
||||
<v-icon icon="mdi-draw-pen" size="16" class="mr-1"/>
|
||||
Canvas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scratchpad -->
|
||||
<div v-if="rightTab === 'scratch'"
|
||||
class="space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-white/[0.02] p-3 sm:p-4"
|
||||
style="max-height: calc(100dvh - 220px);">
|
||||
<div v-for="(fields, group) in groupedFields" :key="group">
|
||||
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-300/80">
|
||||
{{ groupTitles[group] || group }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<label v-for="f in fields" :key="f.key" class="block">
|
||||
<div class="text-[10px] uppercase tracking-wider text-white/45">{{ f.label }}</div>
|
||||
<input
|
||||
v-model="scratch[f.key]"
|
||||
:placeholder="f.placeholder"
|
||||
class="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1.5 text-sm placeholder-white/30 focus:border-cyan-400/60 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div v-else
|
||||
class="rounded-2xl border border-white/10 bg-white/[0.02] p-2 sm:p-3"
|
||||
style="max-height: calc(100dvh - 220px);">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="c in ['#fde68a','#22d3ee','#a78bfa','#f472b6','#ffffff']"
|
||||
:key="c"
|
||||
class="h-6 w-6 rounded-full ring-2 ring-transparent transition"
|
||||
:style="{background: c}"
|
||||
:class="drawColor === c ? 'ring-white' : ''"
|
||||
@click="drawColor = c"
|
||||
/>
|
||||
</div>
|
||||
<input v-model.number="lineWidth" type="range" min="1" max="6" step="0.5" class="w-24"/>
|
||||
<button class="ml-auto rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs hover:bg-white/10" @click="clearCanvas">
|
||||
<v-icon icon="mdi-eraser" size="14" class="mr-1"/>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="block h-[60vh] w-full cursor-crosshair touch-none rounded-xl bg-[#0a0f1c] ring-1 ring-white/10"
|
||||
/>
|
||||
<div class="mt-1 text-[11px] text-white/45">Apple Pencil / Pen wird druckempfindlich erfasst.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.copilot-root {
|
||||
/* iOS Safe Areas im Standalone-Mode */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.timeline-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
23
public/copilot.webmanifest
Normal file
23
public/copilot.webmanifest
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "OpenSquawk Copilot",
|
||||
"short_name": "Copilot",
|
||||
"description": "A320 SOP & VATSIM-Copilot – durchklickbare Timeline mit Scratchpad und Canvas.",
|
||||
"start_url": "/copilot",
|
||||
"scope": "/copilot",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#050910",
|
||||
"theme_color": "#050910",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icon-sm.jpeg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon-sm.jpeg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
server/api/copilot/simbrief.get.ts
Normal file
49
server/api/copilot/simbrief.get.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
|
||||
// Lädt aktuellen SimBrief OFP. Public API: kein Token nötig, Username oder PilotID reicht.
|
||||
// Doku: https://www.simbrief.com/api/xml.fetcher.php
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const username = (Array.isArray(query.username) ? query.username[0] : query.username)?.toString().trim()
|
||||
const userid = (Array.isArray(query.userid) ? query.userid[0] : query.userid)?.toString().trim()
|
||||
if (!username && !userid) {
|
||||
throw createError({statusCode: 400, statusMessage: 'username or userid required'})
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({json: '1'})
|
||||
if (username) params.set('username', username)
|
||||
if (userid) params.set('userid', userid)
|
||||
|
||||
const url = `https://www.simbrief.com/api/xml.fetcher.php?${params.toString()}`
|
||||
const fetcher = (globalThis as any).$fetch as (target: string, options?: Record<string, unknown>) => Promise<any>
|
||||
const ofp = await fetcher(url, {method: 'GET'})
|
||||
|
||||
if (!ofp || ofp.fetch?.status !== 'Success') {
|
||||
throw createError({statusCode: 404, statusMessage: 'No SimBrief OFP found'})
|
||||
}
|
||||
|
||||
return {
|
||||
callsign: ofp.atc?.callsign,
|
||||
flightNumber: ofp.general?.flight_number,
|
||||
aircraftReg: ofp.aircraft?.reg,
|
||||
aircraftIcao: ofp.aircraft?.icao_code,
|
||||
departure: ofp.origin?.icao_code,
|
||||
destination: ofp.destination?.icao_code,
|
||||
altn: ofp.alternate?.icao_code,
|
||||
route: ofp.general?.route,
|
||||
crzFL: ofp.general?.initial_altitude ? `FL${Math.round(Number(ofp.general.initial_altitude) / 100)}` : undefined,
|
||||
costIndex: ofp.general?.costindex,
|
||||
zfw: ofp.weights?.est_zfw ? (Number(ofp.weights.est_zfw) / 1000).toFixed(1) : undefined,
|
||||
blockFuel: ofp.fuel?.plan_ramp ? (Number(ofp.fuel.plan_ramp) / 1000).toFixed(1) : undefined,
|
||||
tripFuel: ofp.fuel?.enroute_burn ? (Number(ofp.fuel.enroute_burn) / 1000).toFixed(1) : undefined,
|
||||
sid: ofp.api_params?.sid_ident || ofp.general?.sid_ident,
|
||||
rwy: ofp.origin?.plan_rwy,
|
||||
transAlt: ofp.origin?.trans_alt,
|
||||
transLevel: ofp.destination?.trans_level,
|
||||
atc: {
|
||||
initial_alt: ofp.atc?.initial_alt,
|
||||
route: ofp.atc?.route,
|
||||
},
|
||||
raw: ofp,
|
||||
}
|
||||
})
|
||||
461
shared/data/a320SopTimeline.ts
Normal file
461
shared/data/a320SopTimeline.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
// A320 SOP Timeline – Cold & Dark bis Handoff nach Takeoff
|
||||
// Quellen: Airbus A320 FCOM/FCTM Standard Procedures + VATSIM/IRL ATC Phraseology
|
||||
// Hinweis: Vereinfacht für Sim-/VATSIM-Nutzung. Immer aktuelle FCOM/Charts checken.
|
||||
|
||||
export type Actor = 'pf' | 'pm' | 'atc' | 'pilot' | 'system' | 'cabin'
|
||||
|
||||
export interface SopStep {
|
||||
id: string
|
||||
actor: Actor
|
||||
label: string // kurz – wird im Timeline-Card oben angezeigt
|
||||
callout?: string // wörtlicher Funkspruch / Callout
|
||||
detail?: string // Erläuterung / wo hinschauen
|
||||
look?: string // Wo der Blick hingeht (für Eyeflow-Hinweis)
|
||||
inputKeys?: string[] // Felder im Scratchpad, die hier relevant sind
|
||||
/** Variant-Branches (horizontale Slides), z. B. "External Power" vs "APU" */
|
||||
variants?: { id: string; title: string; steps: SopStep[] }[]
|
||||
}
|
||||
|
||||
export interface SopPhase {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
color: string // Tailwind-Farbtoken
|
||||
steps: SopStep[]
|
||||
}
|
||||
|
||||
export interface SopAircraftProfile {
|
||||
id: string
|
||||
name: string
|
||||
phases: SopPhase[]
|
||||
}
|
||||
|
||||
const a320Phases: SopPhase[] = [
|
||||
{
|
||||
id: 'cockpit-prep',
|
||||
title: 'Cockpit Preparation',
|
||||
subtitle: 'Cold & Dark → Pre-Flight ready',
|
||||
color: 'cyan',
|
||||
steps: [
|
||||
{
|
||||
id: 'safety-exterior',
|
||||
actor: 'pf',
|
||||
label: 'Safety Exterior / Receive Aircraft',
|
||||
detail: 'Gear pins, chocks in place, GPU/APU plan klären.',
|
||||
look: 'Außenbereich · Fahrwerk · GPU-Status',
|
||||
},
|
||||
{
|
||||
id: 'batteries',
|
||||
actor: 'pf',
|
||||
label: 'Batteries → ON',
|
||||
detail: 'BAT 1 + BAT 2 ON. Voltage check >25.5V.',
|
||||
look: 'Overhead · ELEC Panel',
|
||||
variants: [
|
||||
{
|
||||
id: 'gpu',
|
||||
title: 'External Power verfügbar',
|
||||
steps: [
|
||||
{id: 'ext-pwr', actor: 'pf', label: 'EXT PWR → ON', detail: 'AVAIL Light grün, dann ON drücken.'},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'apu-only',
|
||||
title: 'Nur APU',
|
||||
steps: [
|
||||
{id: 'apu-master', actor: 'pf', label: 'APU MASTER → ON, dann START', detail: 'AVAIL nach ~50 s. APU GEN auto.'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ext-lts',
|
||||
actor: 'pf',
|
||||
label: 'NAV & LOGO Lights → ON',
|
||||
detail: 'Signal nach außen: cockpit besetzt.',
|
||||
look: 'Overhead · EXT LT',
|
||||
},
|
||||
{
|
||||
id: 'cockpit-prep-flow',
|
||||
actor: 'pf',
|
||||
label: 'Cockpit Preparation Flow',
|
||||
detail: 'Overhead von links oben nach rechts unten: ADIRS NAV (3x), Fuel Pumps ON, Anti-ICE OFF, Probes auto, Cabin Press auto, Air Cond auto.',
|
||||
look: 'Overhead Panel · Flow',
|
||||
},
|
||||
{
|
||||
id: 'fmgs-init',
|
||||
actor: 'pf',
|
||||
label: 'FMGS · INIT A',
|
||||
detail: 'CO RTE laden ODER FROM/TO + ALTN, FLT NBR, COST INDEX, CRZ FL setzen.',
|
||||
look: 'MCDU · INIT A',
|
||||
inputKeys: ['departure', 'destination', 'altn', 'flightNumber', 'costIndex', 'crzFL'],
|
||||
},
|
||||
{
|
||||
id: 'fmgs-fpln',
|
||||
actor: 'pf',
|
||||
label: 'FMGS · F-PLN',
|
||||
detail: 'SID, Enroute, STAR/Approach. Discontinuities clearen.',
|
||||
look: 'MCDU · F-PLN',
|
||||
inputKeys: ['sid', 'route'],
|
||||
},
|
||||
{
|
||||
id: 'fmgs-init-b',
|
||||
actor: 'pf',
|
||||
label: 'FMGS · INIT B (Fuel Pred)',
|
||||
detail: 'ZFW/ZFWCG aus Loadsheet, BLOCK fuel.',
|
||||
look: 'MCDU · INIT B',
|
||||
inputKeys: ['zfw', 'zfwcg', 'blockFuel'],
|
||||
},
|
||||
{
|
||||
id: 'fmgs-perf',
|
||||
actor: 'pf',
|
||||
label: 'FMGS · PERF TAKEOFF',
|
||||
detail: 'V1 / VR / V2, FLEX TEMP, THR RED / ACC ALT, TRANS ALT.',
|
||||
look: 'MCDU · PERF · TAKEOFF',
|
||||
inputKeys: ['v1', 'vr', 'v2', 'flexTemp', 'thrRed', 'accAlt'],
|
||||
},
|
||||
{
|
||||
id: 'atis',
|
||||
actor: 'pilot',
|
||||
label: 'ATIS holen (D-ATIS / Voice)',
|
||||
detail: 'Letter, Wind, Vis, RWY, QNH, Trans Level notieren.',
|
||||
inputKeys: ['atisLetter', 'wind', 'qnh', 'rwy', 'transLevel'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'clearance',
|
||||
title: 'Clearance Delivery',
|
||||
subtitle: 'IFR-Freigabe einholen',
|
||||
color: 'sky',
|
||||
steps: [
|
||||
{
|
||||
id: 'init-call',
|
||||
actor: 'pilot',
|
||||
label: 'Initial Call',
|
||||
callout: '«[Station] Delivery, [Callsign], stand [XX], request IFR clearance to [DEST], information [ATIS], ready to copy.»',
|
||||
inputKeys: ['callsign', 'stand', 'destination', 'atisLetter'],
|
||||
},
|
||||
{
|
||||
id: 'clearance-rx',
|
||||
actor: 'atc',
|
||||
label: 'Clearance erhalten',
|
||||
callout: '«[Callsign], cleared to [DEST] via [SID], runway [RWY], climb initially [ALT], squawk [SQK].»',
|
||||
detail: 'CRAFT-Schema mitschreiben.',
|
||||
inputKeys: ['sid', 'initialClimb', 'squawk'],
|
||||
},
|
||||
{
|
||||
id: 'readback',
|
||||
actor: 'pilot',
|
||||
label: 'Readback',
|
||||
callout: '«Cleared to [DEST] via [SID], runway [RWY], climb [ALT], squawk [SQK], [Callsign].»',
|
||||
},
|
||||
{
|
||||
id: 'xpdr-set',
|
||||
actor: 'pf',
|
||||
label: 'XPDR Code setzen, FCU ALT prüfen',
|
||||
look: 'Pedestal · ATC · FCU',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pushback-start',
|
||||
title: 'Pushback & Engine Start',
|
||||
color: 'indigo',
|
||||
steps: [
|
||||
{
|
||||
id: 'before-start-flow',
|
||||
actor: 'pf',
|
||||
label: 'Before Start Flow',
|
||||
detail: 'Windows/Doors closed, Beacon ON, Park Brake set, Thrust Levers IDLE, ENG MODE selector NORM.',
|
||||
},
|
||||
{
|
||||
id: 'request-start',
|
||||
actor: 'pilot',
|
||||
label: 'Pushback & Startup anfordern',
|
||||
callout: '«[Station] Ground, [Callsign], stand [XX], request pushback and startup.»',
|
||||
inputKeys: ['callsign', 'stand'],
|
||||
},
|
||||
{
|
||||
id: 'pushback-clr',
|
||||
actor: 'atc',
|
||||
label: 'Push & Start approved',
|
||||
callout: '«[Callsign], pushback and startup approved, face [direction].»',
|
||||
},
|
||||
{
|
||||
id: 'cabin-secure',
|
||||
actor: 'cabin',
|
||||
label: 'Cabin secure / Doors closed',
|
||||
detail: 'PA: «Cabin crew, doors on automatic, cross-check.»',
|
||||
},
|
||||
{
|
||||
id: 'beacon-on',
|
||||
actor: 'pf',
|
||||
label: 'BEACON → ON',
|
||||
look: 'Overhead · EXT LT',
|
||||
},
|
||||
{
|
||||
id: 'eng-start',
|
||||
actor: 'pf',
|
||||
label: 'Engine Start',
|
||||
detail: 'ENG MODE selector → IGN/START. ENG MASTER 2 → ON, abwarten bis stabil, dann ENG MASTER 1 → ON. N1, EGT, N2, FF überwachen.',
|
||||
look: 'ECAM ENG · Pedestal',
|
||||
},
|
||||
{
|
||||
id: 'after-start-flow',
|
||||
actor: 'pf',
|
||||
label: 'After Start Flow',
|
||||
detail: 'ENG MODE → NORM, APU BLEED OFF (wenn Pack ON kommt), Anti-ICE wie nötig, Pitch Trim setzen, RUDDER Trim 0, Flaps CONF 1+F oder 2 (perf-abhängig), Spoilers ARM, ECAM Status check.',
|
||||
inputKeys: ['flapsConfig', 'pitchTrim'],
|
||||
},
|
||||
{
|
||||
id: 'flight-controls',
|
||||
actor: 'pf',
|
||||
label: 'Flight Controls Check',
|
||||
callout: '«Full left … full right … neutral … full forward … full back … neutral.»',
|
||||
detail: 'PM bestätigt jede Bewegung über ECAM F/CTL Page.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'taxi',
|
||||
title: 'Taxi',
|
||||
color: 'emerald',
|
||||
steps: [
|
||||
{
|
||||
id: 'request-taxi',
|
||||
actor: 'pilot',
|
||||
label: 'Taxi request',
|
||||
callout: '«[Station] Ground, [Callsign], request taxi.»',
|
||||
},
|
||||
{
|
||||
id: 'taxi-clr',
|
||||
actor: 'atc',
|
||||
label: 'Taxi clearance',
|
||||
callout: '«[Callsign], taxi to holding point [RWY] via [TWY A, B, C].»',
|
||||
inputKeys: ['taxiRoute', 'holdingPoint'],
|
||||
},
|
||||
{
|
||||
id: 'taxi-readback',
|
||||
actor: 'pilot',
|
||||
label: 'Readback',
|
||||
callout: '«Taxi to holding point [RWY] via [TWY], [Callsign].»',
|
||||
},
|
||||
{
|
||||
id: 'taxi-lights',
|
||||
actor: 'pf',
|
||||
label: 'NOSE Light → TAXI, Brakes check',
|
||||
detail: 'PM: «Brakes checked.» max 30 kts gerade, max 10 kts in Kurven.',
|
||||
},
|
||||
{
|
||||
id: 'before-tko-checklist',
|
||||
actor: 'pm',
|
||||
label: 'Before Takeoff Checklist (down to the line)',
|
||||
detail: 'Flaps · Pitch Trim · Flight Controls · ECAM Memo: T.O. NO BLUE.',
|
||||
},
|
||||
{
|
||||
id: 'takeoff-briefing',
|
||||
actor: 'pf',
|
||||
label: 'Takeoff Briefing',
|
||||
detail: 'Standard: dep RWY [XX], SID [..], engine failure plan, return gates, MSA, weather.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lineup-tko',
|
||||
title: 'Line-Up & Takeoff',
|
||||
color: 'amber',
|
||||
steps: [
|
||||
{
|
||||
id: 'handoff-twr',
|
||||
actor: 'atc',
|
||||
label: 'Handoff Tower',
|
||||
callout: '«[Callsign], contact Tower [FREQ].»',
|
||||
inputKeys: ['twrFreq'],
|
||||
},
|
||||
{
|
||||
id: 'twr-call',
|
||||
actor: 'pilot',
|
||||
label: 'Tower Call',
|
||||
callout: '«[Station] Tower, [Callsign], holding short [RWY].»',
|
||||
},
|
||||
{
|
||||
id: 'lineup',
|
||||
actor: 'atc',
|
||||
label: 'Line-up clearance',
|
||||
callout: '«[Callsign], line up runway [RWY] and wait.»',
|
||||
},
|
||||
{
|
||||
id: 'before-tko-line',
|
||||
actor: 'pf',
|
||||
label: 'Below-the-line Items',
|
||||
detail: 'Cabin Crew advised, Packs/Anti-Ice per perf, T.O. CONFIG TEST gedrückt, Strobes ON, TCAS TA/RA, Landing Lights ON, NOSE Light → T.O.',
|
||||
callout: 'Cabin: «Cabin crew, takeoff.»',
|
||||
},
|
||||
{
|
||||
id: 'cleared-tko',
|
||||
actor: 'atc',
|
||||
label: 'Cleared for Takeoff',
|
||||
callout: '«[Callsign], wind [..], runway [RWY], cleared for takeoff.»',
|
||||
},
|
||||
{
|
||||
id: 'tko-roll',
|
||||
actor: 'pf',
|
||||
label: 'Takeoff Roll',
|
||||
detail: 'Thrust Levers 50 % N1, stabil, dann FLEX/TOGA. PF hand auf Thrust bis V1.',
|
||||
},
|
||||
{
|
||||
id: 'thrust-set',
|
||||
actor: 'pm',
|
||||
label: 'Callout: «Thrust set»',
|
||||
callout: '«Thrust set, FMA: MAN FLEX [TEMP] / SRS / RWY.»',
|
||||
look: 'PFD · FMA',
|
||||
},
|
||||
{
|
||||
id: 'kts-100',
|
||||
actor: 'pm',
|
||||
label: '«100 knots»',
|
||||
callout: '«100 knots.»',
|
||||
look: 'PFD · Speed Tape',
|
||||
detail: 'PF antwortet «checked».',
|
||||
},
|
||||
{
|
||||
id: 'v1',
|
||||
actor: 'pm',
|
||||
label: '«V1»',
|
||||
callout: '«V1.»',
|
||||
detail: 'PF nimmt Hand vom Thrust Lever.',
|
||||
},
|
||||
{
|
||||
id: 'rotate',
|
||||
actor: 'pm',
|
||||
label: '«Rotate»',
|
||||
callout: '«Rotate.»',
|
||||
detail: 'PF: smooth pitch up zu 12.5° → ca. 15° Initial.',
|
||||
},
|
||||
{
|
||||
id: 'positive-climb',
|
||||
actor: 'pm',
|
||||
label: '«Positive climb»',
|
||||
callout: 'PM: «Positive climb.» — PF: «Gear up.»',
|
||||
look: 'PFD · V/S, Altitude',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'after-tko',
|
||||
title: 'After Takeoff & Handoff',
|
||||
color: 'rose',
|
||||
steps: [
|
||||
{
|
||||
id: 'thr-red',
|
||||
actor: 'system',
|
||||
label: 'THR RED ALT erreicht',
|
||||
detail: 'FMA: CLB. Thrust Levers in CL detent.',
|
||||
look: 'PFD · FMA',
|
||||
inputKeys: ['thrRed'],
|
||||
},
|
||||
{
|
||||
id: 'acc-alt',
|
||||
actor: 'system',
|
||||
label: 'ACC ALT erreicht',
|
||||
detail: 'Auto-Beschleunigung auf Green-Dot. Flap-Retraction-Schedule beginnt.',
|
||||
inputKeys: ['accAlt'],
|
||||
},
|
||||
{
|
||||
id: 'flap-1',
|
||||
actor: 'pf',
|
||||
label: '«Flaps 1» bei F-Speed',
|
||||
callout: 'PF: «Flaps 1.» PM: «Speed checked, flaps 1.»',
|
||||
},
|
||||
{
|
||||
id: 'flap-0',
|
||||
actor: 'pf',
|
||||
label: '«Flaps 0» bei S-Speed',
|
||||
callout: 'PF: «Flaps 0.» PM: «Speed checked, flaps 0.»',
|
||||
},
|
||||
{
|
||||
id: 'after-tko-flow',
|
||||
actor: 'pf',
|
||||
label: 'After Takeoff Flow',
|
||||
detail: 'Gear UP & off, Spoilers disarm, Flaps 0, Packs ON falls vorher off, APU MASTER OFF wenn nicht mehr benötigt, Anti-ICE wie nötig, Landing Lts AUTO/OFF (>10.000 ft), NOSE Light OFF, Seatbelts wie nötig.',
|
||||
},
|
||||
{
|
||||
id: 'after-tko-checklist',
|
||||
actor: 'pm',
|
||||
label: 'After Takeoff / Climb Checklist',
|
||||
detail: 'Landing Gear UP · Flaps 0 · Packs ON · Baro: bei TRANS ALT auf STD.',
|
||||
},
|
||||
{
|
||||
id: 'handoff-dep',
|
||||
actor: 'atc',
|
||||
label: 'Handoff Departure / Center',
|
||||
callout: '«[Callsign], contact Departure [FREQ], good day.»',
|
||||
inputKeys: ['depFreq'],
|
||||
},
|
||||
{
|
||||
id: 'dep-call',
|
||||
actor: 'pilot',
|
||||
label: 'Initial Call Departure',
|
||||
callout: '«[Station] Departure, [Callsign], passing [ALT] for [CLEARED ALT], [SID].»',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const a320Profile: SopAircraftProfile = {
|
||||
id: 'a320',
|
||||
name: 'Airbus A320 (CEO/NEO)',
|
||||
phases: a320Phases,
|
||||
}
|
||||
|
||||
export const sopProfiles: SopAircraftProfile[] = [a320Profile]
|
||||
|
||||
/** Felder für den Scratchpad – gruppiert. */
|
||||
export interface ScratchField {
|
||||
key: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
group: 'flight' | 'perf' | 'atc' | 'fuel' | 'env'
|
||||
}
|
||||
|
||||
export const scratchFields: ScratchField[] = [
|
||||
{key: 'callsign', label: 'Callsign', placeholder: 'DLH4AB', group: 'flight'},
|
||||
{key: 'flightNumber', label: 'Flight Nbr', placeholder: 'DLH441', group: 'flight'},
|
||||
{key: 'aircraftReg', label: 'Aircraft Reg', placeholder: 'D-AIZA', group: 'flight'},
|
||||
{key: 'departure', label: 'Departure', placeholder: 'EDDF', group: 'flight'},
|
||||
{key: 'destination', label: 'Destination', placeholder: 'LEMD', group: 'flight'},
|
||||
{key: 'altn', label: 'Alternate', placeholder: 'LEBL', group: 'flight'},
|
||||
{key: 'route', label: 'Route', placeholder: 'TOBAK Y163 ...', group: 'flight'},
|
||||
{key: 'crzFL', label: 'CRZ FL', placeholder: 'FL360', group: 'flight'},
|
||||
{key: 'costIndex', label: 'Cost Index', placeholder: '30', group: 'flight'},
|
||||
{key: 'stand', label: 'Stand / Gate', placeholder: 'V152', group: 'flight'},
|
||||
|
||||
{key: 'atisLetter', label: 'ATIS', placeholder: 'D', group: 'env'},
|
||||
{key: 'wind', label: 'Wind', placeholder: '250/08', group: 'env'},
|
||||
{key: 'qnh', label: 'QNH', placeholder: '1013', group: 'env'},
|
||||
{key: 'rwy', label: 'RWY', placeholder: '25C', group: 'env'},
|
||||
{key: 'transLevel', label: 'Trans Level', placeholder: 'FL70', group: 'env'},
|
||||
{key: 'transAlt', label: 'Trans Alt', placeholder: '5000', group: 'env'},
|
||||
|
||||
{key: 'sid', label: 'SID', placeholder: 'OBOKA1L', group: 'atc'},
|
||||
{key: 'initialClimb', label: 'Initial Climb', placeholder: '5000 ft', group: 'atc'},
|
||||
{key: 'squawk', label: 'Squawk', placeholder: '1000', group: 'atc'},
|
||||
{key: 'taxiRoute', label: 'Taxi Route', placeholder: 'N, L, M', group: 'atc'},
|
||||
{key: 'holdingPoint', label: 'Holding Point', placeholder: 'S4', group: 'atc'},
|
||||
{key: 'twrFreq', label: 'Tower', placeholder: '119.905', group: 'atc'},
|
||||
{key: 'depFreq', label: 'Departure', placeholder: '120.150', group: 'atc'},
|
||||
|
||||
{key: 'zfw', label: 'ZFW (t)', placeholder: '60.5', group: 'fuel'},
|
||||
{key: 'zfwcg', label: 'ZFWCG (%)', placeholder: '27.5', group: 'fuel'},
|
||||
{key: 'blockFuel', label: 'Block Fuel (t)', placeholder: '12.4', group: 'fuel'},
|
||||
{key: 'tripFuel', label: 'Trip Fuel (t)', placeholder: '8.7', group: 'fuel'},
|
||||
|
||||
{key: 'flapsConfig', label: 'Flaps T/O', placeholder: '1+F', group: 'perf'},
|
||||
{key: 'flexTemp', label: 'FLEX TEMP', placeholder: '52', group: 'perf'},
|
||||
{key: 'v1', label: 'V1', placeholder: '142', group: 'perf'},
|
||||
{key: 'vr', label: 'VR', placeholder: '146', group: 'perf'},
|
||||
{key: 'v2', label: 'V2', placeholder: '150', group: 'perf'},
|
||||
{key: 'thrRed', label: 'THR RED', placeholder: '1500', group: 'perf'},
|
||||
{key: 'accAlt', label: 'ACC ALT', placeholder: '1500', group: 'perf'},
|
||||
{key: 'pitchTrim', label: 'Pitch Trim', placeholder: 'DN 1.2', group: 'perf'},
|
||||
]
|
||||
Reference in New Issue
Block a user