first version looks ok

This commit is contained in:
itsrubberduck
2026-04-25 20:29:09 +02:00
parent e716262fe2
commit bbcd9dca54
4 changed files with 1171 additions and 0 deletions

638
app/pages/copilot.vue Normal file
View 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>

View 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"
}
]
}

View 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,
}
})

View 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'},
]