Files
OpenSquawk/app/pages/copilot.vue
2026-05-10 17:15:41 +02:00

2590 lines
68 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {computed, defineComponent, h, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
import {VMenu} from 'vuetify/components'
import {a320Profile, glossary, scratchFields, type SopPhase, type SopStep} from '~~/shared/data/a320SopTimeline'
// RichText: rendert Text mit Glossar-Pills + Scratchpad-Placeholders
const RichText = defineComponent({
name: 'RichText',
props: {
tokens: {type: Array as () => any[], required: true},
resolve: {type: Function as any, required: true},
glossary: {type: Object as any, required: true},
},
setup(props) {
return () => h('span', {class: 'rt'},
(props.tokens as any[]).map((seg, i) => {
if (seg.type === 'placeholder') {
const v = props.resolve(seg.placeholderKey)
if (v) return h('span', {key: i, class: 'rt-placeholder filled'}, v)
return h('span', {key: i, class: 'rt-placeholder'}, seg.value)
}
if (seg.type === 'term') {
const entry = (props.glossary as Map<string, any>).get(seg.term)
if (!entry) return h('span', {key: i}, seg.value)
return h(VMenu as any, {
key: i,
openOnHover: true,
openOnClick: true,
location: 'top',
offset: 6,
closeOnContentClick: false,
}, {
activator: ({props: a}: any) => h('span', {...a, class: 'rt-term'}, seg.value),
default: () => h('div', {class: 'rt-term-popover'}, [
h('div', {class: 'rt-term-head'}, entry.term),
h('div', {class: 'rt-term-short'}, entry.short),
entry.long ? h('div', {class: 'rt-term-long'}, entry.long) : null,
]),
})
}
return h('span', {key: i}, seg.value)
}),
)
},
})
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: '#0b1020'},
{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.v5'
const activeProfile = ref(a320Profile)
const phases = computed<SopPhase[]>(() => activeProfile.value.phases)
const scratch = ref<Record<string, string>>({})
const variantSel = ref<Record<string, string>>({})
const expanded = ref<Record<string, boolean>>({})
const activeStepId = ref<string | null>(null)
const simbriefUser = ref('')
const simbriefLoading = ref(false)
const simbriefError = ref('')
const showCanvas = ref(true)
const showSimbrief = ref(false)
const asideHeight = ref(280)
const simbriefWrapRef = ref<HTMLElement | null>(null)
const simbriefPopPos = ref<Record<string, string>>({})
// View / Filter / Columns
type ViewMode = 'timeline' | 'table'
const viewMode = ref<ViewMode>('timeline')
const ALL_ACTORS = ['pilot', 'pf', 'pm', 'atc', 'cabin', 'system'] as const
type ActorKey = typeof ALL_ACTORS[number]
const actorFilter = ref<Record<ActorKey, boolean>>({
pilot: true, pf: true, pm: true, atc: true, cabin: true, system: true,
})
type ColKey = 'idx' | 'phase' | 'actor' | 'label' | 'callout' | 'detail' | 'inputs'
const ALL_COLS: { key: ColKey; label: string }[] = [
{key: 'idx', label: '#'},
{key: 'phase', label: 'Phase'},
{key: 'actor', label: 'Wer'},
{key: 'label', label: 'Step'},
{key: 'callout', label: 'Callout'},
{key: 'detail', label: 'Detail'},
{key: 'inputs', label: 'Inputs'},
]
const visibleCols = ref<Record<ColKey, boolean>>({
idx: true, phase: true, actor: true, label: true, callout: true, detail: false, inputs: false,
})
const showFilterMenu = ref(false)
const showColumnMenu = ref(false)
const filterWrapRef = ref<HTMLElement | null>(null)
const columnWrapRef = ref<HTMLElement | null>(null)
const filterPopPos = ref<Record<string, string>>({})
const columnPopPos = ref<Record<string, string>>({})
function positionPopover(triggerRef: any, posRef: any) {
if (!triggerRef.value) return
nextTick(() => {
const rect = triggerRef.value.getBoundingClientRect()
posRef.value = {
top: (rect.bottom + 6) + 'px',
right: Math.max(8, window.innerWidth - rect.right) + 'px',
}
})
}
watch(showFilterMenu, (v) => {
if (v) {
showColumnMenu.value = false
showSimbrief.value = false
positionPopover(filterWrapRef, filterPopPos)
}
})
watch(showColumnMenu, (v) => {
if (v) {
showFilterMenu.value = false
showSimbrief.value = false
positionPopover(columnWrapRef, columnPopPos)
}
})
function toggleActorFilter(a: ActorKey) {
actorFilter.value[a] = !actorFilter.value[a]
if (!Object.values(actorFilter.value).some(Boolean)) actorFilter.value[a] = true
}
function toggleColumn(c: ColKey) {
visibleCols.value[c] = !visibleCols.value[c]
if (!Object.values(visibleCols.value).some(Boolean)) visibleCols.value[c] = true
}
function setAllActors(on: boolean) {
for (const a of ALL_ACTORS) actorFilter.value[a] = on
}
function onlyActor(a: ActorKey) {
for (const k of ALL_ACTORS) actorFilter.value[k] = false
actorFilter.value[a] = true
}
const visibleColList = computed(() => ALL_COLS.filter(c => visibleCols.value[c.key]))
const activeFilterCount = computed(() => ALL_ACTORS.filter(a => !actorFilter.value[a]).length)
const hiddenColCount = computed(() => ALL_COLS.filter(c => !visibleCols.value[c.key]).length)
function phaseHasVisibleStep(p: SopPhase): boolean {
for (const s of p.steps) {
if (actorFilter.value[s.actor as ActorKey]) return true
if (s.variants?.length) {
const sel = variantSel.value[s.id] || s.variants[0].id
const variant = s.variants.find(v => v.id === sel)
if (variant && variant.steps.some(cs => actorFilter.value[cs.actor as ActorKey])) return true
}
}
return false
}
function stepIsVisible(s: SopStep): boolean {
return actorFilter.value[s.actor as ActorKey] || !!(s.variants?.length)
}
watch(showSimbrief, (v) => {
if (v && simbriefWrapRef.value) {
nextTick(() => {
const rect = simbriefWrapRef.value!.getBoundingClientRect()
simbriefPopPos.value = {
top: (rect.bottom + 6) + 'px',
right: Math.max(8, window.innerWidth - rect.right) + 'px',
}
})
}
})
// Glossary
const glossaryByTerm = new Map(glossary.map(g => [g.term.toUpperCase(), g]))
const glossaryRegex = (() => {
const sorted = [...glossary].sort((a, b) => b.term.length - a.term.length)
const escaped = sorted.map(g => g.term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
return new RegExp(`\\b(${escaped.join('|')})\\b`, 'g')
})()
interface Segment {
type: 'text' | 'term' | 'placeholder'
value: string
term?: string
placeholderKey?: string
}
function tokenize(input: string): Segment[] {
if (!input) return []
const out: Segment[] = []
const placeholderRx = /\[([A-Z0-9_ +/-]+)\]/g
let last = 0
let m: RegExpExecArray | null
while ((m = placeholderRx.exec(input)) !== null) {
if (m.index > last) out.push(...splitGlossary(input.slice(last, m.index)))
const key = m[1].trim()
out.push({type: 'placeholder', value: m[0], placeholderKey: key})
last = m.index + m[0].length
}
if (last < input.length) out.push(...splitGlossary(input.slice(last)))
return out
}
function splitGlossary(text: string): Segment[] {
const out: Segment[] = []
let last = 0
let m: RegExpExecArray | null
glossaryRegex.lastIndex = 0
while ((m = glossaryRegex.exec(text)) !== null) {
if (m.index > last) out.push({type: 'text', value: text.slice(last, m.index)})
out.push({type: 'term', value: m[0], term: m[0].toUpperCase()})
last = m.index + m[0].length
}
if (last < text.length) out.push({type: 'text', value: text.slice(last)})
return out
}
function placeholderValue(key: string): string {
const map: Record<string, string> = {
Callsign: 'callsign', CALLSIGN: 'callsign',
DEST: 'destination',
SID: 'sid',
STAR: 'star',
RWY: 'rwy',
ARR_RWY: 'arrivalRunway',
STAND: 'stand',
GATE: 'gate',
ATIS: 'atisLetter',
ARR_ATIS: 'arrivalAtis',
SQK: 'squawk',
INIT_ALT: 'initialClimb',
PASSING_ALT: 'initialClimb',
CRZ_FL: 'crzFL',
TWR_FREQ: 'twrFreq',
DEP_FREQ: 'depFreq',
APP_FREQ: 'appFreq',
GROUND_FREQ: 'groundFreq',
FREQ: 'depFreq',
WIND: 'wind',
ARR_QNH: 'arrivalQnh',
FLEX_TEMP: 'flexTemp',
TAXI_ROUTE: 'taxiRoute',
HOLDING: 'holdingPoint',
APPROACH: 'approach',
LDG_CONF: 'landingConfig',
VAPP: 'vapp',
MINIMUMS: 'minimums',
DECISION_ALT: 'decisionAlt',
DIRECTION: '',
Station: '',
}
const fieldKey = map[key]
if (fieldKey) return scratch.value[fieldKey] || ''
return ''
}
// 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
}
}
// Steps mit Variants flach
function stepsForPhase(phase: SopPhase): SopStep[] {
const out: SopStep[] = []
for (const s of phase.steps) {
out.push(s)
if (s.variants?.length) {
const sel = variantSel.value[s.id] || s.variants[0].id
const variant = s.variants.find(v => v.id === sel)
if (variant) out.push(...variant.steps)
}
}
return out
}
const allSteps = computed(() => {
const out: { phase: SopPhase; step: SopStep; idx: number }[] = []
let i = 0
for (const p of phases.value) for (const s of stepsForPhase(p)) out.push({phase: p, step: s, idx: i++})
return out
})
// Gefiltert nach Actor-Typ. Wird in Timeline + Tabelle + Tastatur-Navigation genutzt.
const filteredSteps = computed(() => allSteps.value.filter(s => actorFilter.value[s.step.actor as ActorKey]))
const activeIdx = computed(() => filteredSteps.value.findIndex(s => s.step.id === activeStepId.value))
const activeStep = computed(() => filteredSteps.value[activeIdx.value])
const phaseProgress = computed(() => {
const ai = activeIdx.value
const out: Record<string, { done: number; total: number; pct: number }> = {}
for (const p of phases.value) {
const steps = stepsForPhase(p).filter(s => actorFilter.value[s.actor as ActorKey])
const total = steps.length
let done = 0
for (const s of steps) {
const idx = filteredSteps.value.findIndex(a => a.step.id === s.id)
if (idx >= 0 && idx < ai) done++
}
out[p.id] = {done, total, pct: total ? Math.round(done / total * 100) : 0}
}
return out
})
const totalProgress = computed(() => {
const total = filteredSteps.value.length
if (!total || activeIdx.value < 0) return 0
return Math.round((activeIdx.value / total) * 100)
})
function positionClass(idx: number): string {
const ai = activeIdx.value
if (idx === ai) return 'is-active'
const d = idx - ai
if (d < 0) return Math.abs(d) <= 1 ? 'is-past is-near' : 'is-past'
return d <= 1 ? 'is-future is-near' : 'is-future'
}
// Scroll-Lock: verhindert, dass IntersectionObserver activeStepId überschreibt,
// während wir gerade per Tastatur/Klick zu einem Step scrollen. Timer wird bei
// jedem neuen Navigationsruf zurückgesetzt — so springt es bei schnellen
// Tastendrücken nicht mehr.
let scrollLock = false
let scrollLockTimer: ReturnType<typeof setTimeout> | null = null
let pendingScrollId: string | null = null
function lockScroll(duration = 750) {
scrollLock = true
if (scrollLockTimer) clearTimeout(scrollLockTimer)
scrollLockTimer = setTimeout(() => {
scrollLock = false
scrollLockTimer = null
pendingScrollId = null
}, duration)
}
function setActive(id: string) {
activeStepId.value = id
nextTick(() => scrollToStep(id))
}
function nextStep() {
if (viewMode.value === 'table') {
// In Tabellen-Modus reicht reines Highlighten das Scrollen erledigt der
// smooth-scroll auf die aktive Tabellenzeile.
if (activeIdx.value < 0 || activeIdx.value >= filteredSteps.value.length - 1) return
activeStepId.value = filteredSteps.value[activeIdx.value + 1].step.id
nextTick(() => scrollToTableRow(activeStepId.value!))
return
}
if (activeIdx.value < 0 || activeIdx.value >= filteredSteps.value.length - 1) return
activeStepId.value = filteredSteps.value[activeIdx.value + 1].step.id
nextTick(() => scrollToStep(activeStepId.value!))
}
function prevStep() {
if (viewMode.value === 'table') {
if (activeIdx.value <= 0) return
activeStepId.value = filteredSteps.value[activeIdx.value - 1].step.id
nextTick(() => scrollToTableRow(activeStepId.value!))
return
}
if (activeIdx.value <= 0) return
activeStepId.value = filteredSteps.value[activeIdx.value - 1].step.id
nextTick(() => scrollToStep(activeStepId.value!))
}
function scrollToStep(id: string) {
const el = document.querySelector(`.timeline [data-step-id="${id}"]`) as HTMLElement | null
if (!el) return
pendingScrollId = id
lockScroll(750)
el.scrollIntoView({behavior: 'smooth', block: 'center'})
}
function scrollToTableRow(id: string) {
const el = document.querySelector(`.steps-table tr[data-step-id="${id}"]`) as HTMLElement | null
if (!el) return
pendingScrollId = id
lockScroll(450)
el.scrollIntoView({behavior: 'smooth', block: 'nearest'})
}
function toggleWhy(id: string) {
lockScroll()
expanded.value[id] = !expanded.value[id]
nextTick(() => {
scrollToStep(id)
})
}
function selectVariant(stepId: string, variantId: string) {
lockScroll()
variantSel.value[stepId] = variantId
nextTick(() => scrollToStep(stepId))
}
function cycleVariant(dir: 1 | -1) {
const cur = activeStep.value
if (!cur) return
let owner: SopStep | null = null
for (const p of phases.value) {
for (const s of p.steps) {
if (!s.variants?.length) continue
if (s.id === cur.step.id) {
owner = s
break
}
const sel = variantSel.value[s.id] || s.variants[0].id
const v = s.variants.find(x => x.id === sel)
if (v?.steps.some(cs => cs.id === cur.step.id)) {
owner = s
break
}
}
if (owner) break
}
if (!owner || !owner.variants?.length) return
const ids = owner.variants.map(v => v.id)
const cursel = variantSel.value[owner.id] || ids[0]
const idx = ids.indexOf(cursel)
const next = ids[(idx + dir + ids.length) % ids.length]
selectVariant(owner.id, next)
}
function jumpToPhase(id: string) {
const phase = phases.value.find(p => p.id === id)
if (!phase || !phase.steps[0]) return
setActive(phase.steps[0].id)
}
function resetAll() {
if (!confirm('Alle Eingaben, Fortschritt & Canvas löschen?')) return
scratch.value = {}
variantSel.value = {}
expanded.value = {}
activeStepId.value = filteredSteps.value[0]?.step.id || phases.value[0]?.steps[0]?.id || null
canvasImage.value = ''
clearCanvas()
persist()
}
// Persistence
const canvasImage = ref<string>('')
function persist() {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify({
scratch: scratch.value,
variantSel: variantSel.value,
activeStepId: activeStepId.value,
simbriefUser: simbriefUser.value,
showCanvas: showCanvas.value,
canvasImage: canvasImage.value,
asideHeight: asideHeight.value,
viewMode: viewMode.value,
actorFilter: actorFilter.value,
visibleCols: visibleCols.value,
}))
}
watch([scratch, variantSel, activeStepId, showCanvas, simbriefUser, asideHeight, viewMode, actorFilter, visibleCols], persist, {deep: true})
// Keyboard
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault()
nextStep()
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
prevStep()
} else if (e.key === 'Enter') {
e.preventDefault()
nextStep()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
cycleVariant(-1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
cycleVariant(1)
} else if (e.key === 'i' || e.key === 'I') {
if (activeStepId.value) toggleWhy(activeStepId.value)
}
}
// Active-Step durch Scrollen erkennen
let observer: IntersectionObserver | null = null
function setupObserver() {
if (typeof IntersectionObserver === 'undefined') return
observer?.disconnect()
if (viewMode.value !== 'timeline') return
const root = document.querySelector('.timeline') as HTMLElement | null
if (!root) return
observer = new IntersectionObserver((entries) => {
// Keine Updates während Tastatur/Klick-Scroll sonst springt der active Step
if (scrollLock) return
let best: { id: string; ratio: number } | null = null
for (const e of entries) {
if (e.isIntersecting) {
const id = (e.target as HTMLElement).dataset.stepId
if (!id) continue
if (!best || e.intersectionRatio > best.ratio) best = {id, ratio: e.intersectionRatio}
}
}
if (best && best.id !== activeStepId.value) activeStepId.value = best.id
}, {root, rootMargin: '-45% 0px -45% 0px', threshold: [0, 0.25, 0.5, 0.75, 1]})
document.querySelectorAll('.timeline [data-step-id]').forEach(el => observer?.observe(el))
}
watch(filteredSteps, () => nextTick(setupObserver))
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.variantSel) variantSel.value = v.variantSel
if (v.activeStepId) activeStepId.value = v.activeStepId
if (typeof v.simbriefUser === 'string') simbriefUser.value = v.simbriefUser
if (typeof v.showCanvas === 'boolean') showCanvas.value = v.showCanvas
if (typeof v.canvasImage === 'string') canvasImage.value = v.canvasImage
if (typeof v.asideHeight === 'number') asideHeight.value = v.asideHeight
if (v.viewMode === 'timeline' || v.viewMode === 'table') viewMode.value = v.viewMode
if (v.actorFilter && typeof v.actorFilter === 'object') {
for (const a of ALL_ACTORS) {
if (typeof v.actorFilter[a] === 'boolean') actorFilter.value[a] = v.actorFilter[a]
}
}
if (v.visibleCols && typeof v.visibleCols === 'object') {
for (const c of ALL_COLS) {
if (typeof v.visibleCols[c.key] === 'boolean') visibleCols.value[c.key] = v.visibleCols[c.key]
}
}
} catch {
}
}
}
if (!activeStepId.value && allSteps.value.length) activeStepId.value = allSteps.value[0].step.id
// Falls aktiver Step durch Filter weggefiltert ist → ersten sichtbaren wählen
if (activeStepId.value && !filteredSteps.value.some(s => s.step.id === activeStepId.value)) {
activeStepId.value = filteredSteps.value[0]?.step.id || activeStepId.value
}
nextTick(() => {
setupObserver()
if (activeStepId.value) scrollToStep(activeStepId.value)
setupCanvas()
})
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onDocMouseDown)
})
function onDocMouseDown(e: MouseEvent) {
const t = e.target as HTMLElement
if (showFilterMenu.value && !t.closest('.filter-pop') && !t.closest('.filter-trigger')) showFilterMenu.value = false
if (showColumnMenu.value && !t.closest('.column-pop') && !t.closest('.column-trigger')) showColumnMenu.value = false
}
// Filter-Änderung: Falls aktiver Step rausfällt → näher auf den nächsten sichtbaren springen
watch(actorFilter, () => {
if (!filteredSteps.value.length) return
if (!filteredSteps.value.some(s => s.step.id === activeStepId.value)) {
activeStepId.value = filteredSteps.value[0].step.id
nextTick(() => {
if (viewMode.value === 'timeline') scrollToStep(activeStepId.value!)
else scrollToTableRow(activeStepId.value!)
})
}
}, {deep: true})
watch(viewMode, (v) => {
// Beim View-Wechsel: kurz scrollen zum aktiven Step
nextTick(() => {
if (!activeStepId.value) return
if (v === 'timeline') {
setupObserver()
scrollToStep(activeStepId.value)
} else {
scrollToTableRow(activeStepId.value)
}
})
})
onUnmounted(() => {
observer?.disconnect()
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onDocMouseDown)
if (scrollLockTimer) clearTimeout(scrollLockTimer)
})
// Resize Handle für Scratchpad
let resizing = false
let resizeStartY = 0
let resizeStartH = 0
function onResizeStart(e: PointerEvent) {
resizing = true
resizeStartY = e.clientY
resizeStartH = asideHeight.value
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}
function onResizeMove(e: PointerEvent) {
if (!resizing) return
const dy = resizeStartY - e.clientY
asideHeight.value = Math.max(100, Math.min(resizeStartH + dy, window.innerHeight * 0.8))
}
function onResizeEnd(e: PointerEvent) {
if (!resizing) return
resizing = false
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
persist()
}
// Canvas
const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvasWrapRef = ref<HTMLElement | null>(null)
const drawColor = '#fde68a'
const lineWidth = 2.6
let canvasInited = false
function loadCanvasImage() {
const cv = canvasRef.value
if (!cv || !canvasImage.value) return
const img = new Image()
img.onload = () => {
const ctx = cv.getContext('2d')!
const r = cv.getBoundingClientRect()
ctx.drawImage(img, 0, 0, r.width, r.height)
}
img.src = canvasImage.value
}
function saveCanvasImage() {
const cv = canvasRef.value
if (!cv) return
try {
canvasImage.value = cv.toDataURL('image/png')
persist()
} catch {
}
}
function setupCanvas() {
const cv = canvasRef.value
if (!cv || canvasInited) return
canvasInited = true
const dpr = window.devicePixelRatio || 1
const resize = () => {
const rect = cv.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const prev = canvasImage.value
cv.width = rect.width * dpr
cv.height = rect.height * dpr
const ctx = cv.getContext('2d')!
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
if (prev) {
const img = new Image()
img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height)
img.src = prev
}
}
resize()
new ResizeObserver(resize).observe(cv)
if (canvasImage.value) nextTick(loadCanvasImage)
let drawing = false
let last: { x: number; y: number; p: number } | null = null
const pos = (e: PointerEvent) => {
const r = cv.getBoundingClientRect()
return {x: e.clientX - r.left, y: e.clientY - r.top, p: e.pressure > 0 ? e.pressure : 0.5}
}
cv.addEventListener('pointerdown', (e) => {
e.preventDefault()
cv.setPointerCapture(e.pointerId)
drawing = true
last = pos(e)
})
cv.addEventListener('pointermove', (e) => {
if (!drawing || !last) return
e.preventDefault()
const ctx = cv.getContext('2d')!
const cur = pos(e)
ctx.strokeStyle = drawColor
ctx.lineWidth = lineWidth * (0.5 + cur.p)
ctx.beginPath()
ctx.moveTo(last.x, last.y)
ctx.lineTo(cur.x, cur.y)
ctx.stroke()
last = cur
})
const stop = (e: PointerEvent) => {
if (!drawing) return
drawing = false
last = null
if (cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId)
saveCanvasImage()
}
cv.addEventListener('pointerup', stop)
cv.addEventListener('pointercancel', stop)
cv.addEventListener('pointerleave', stop)
}
watch(showCanvas, (v) => {
if (v) nextTick(setupCanvas)
})
function clearCanvas() {
const cv = canvasRef.value
if (!cv) return
const ctx = cv.getContext('2d')!
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, cv.width, cv.height)
ctx.restore()
canvasImage.value = ''
persist()
if (canvasWrapRef.value) canvasWrapRef.value.scrollTop = 0
}
function cleanfeedCanvas() {
const cv = canvasRef.value
const wrap = canvasWrapRef.value
if (!cv) return
const ctx = cv.getContext('2d')!
const dpr = window.devicePixelRatio || 1
// shift = 40% der sichtbaren Wrapper-Höhe
const wrapH = wrap ? wrap.clientHeight : 400
const shift = Math.round(wrapH * 0.4)
const img = ctx.getImageData(0, 0, cv.width, cv.height)
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, cv.width, cv.height)
ctx.putImageData(img, 0, -shift * dpr)
ctx.restore()
// Nach Shift: direkt zum neuen freien Bereich unten scrollen
if (wrap) wrap.scrollTop += shift
saveCanvasImage()
}
// Actor-Meta
const actorIcon: Record<string, string> = {
pilot: 'mdi-radio-handheld',
pf: 'mdi-airplane',
pm: 'mdi-eye-check',
atc: 'mdi-tower-fire',
cabin: 'mdi-account-group',
system: 'mdi-cog-sync-outline',
}
const actorLabel: Record<string, string> = {
pilot: 'Funkspruch · DU',
pf: 'Pilot Flying',
pm: 'Pilot Monitoring',
atc: 'ATC sagt',
cabin: 'Cabin Crew',
system: 'Flugzeug',
}
</script>
<template>
<div class="copilot scene learn-theme">
<!-- HUD -->
<header class="hud">
<div class="hud-inner">
<div class="hud-left">
<NuxtLink to="/" class="hud-logo" title="Zurück">
<v-icon size="22">mdi-radar</v-icon>
</NuxtLink>
<div class="hud-divider"/>
<div class="hud-brand">
<div class="brand-name">Copilot</div>
<div class="brand-mode">{{ activeProfile.name }}</div>
</div>
</div>
<div class="hud-right">
<!-- View Toggle: Timeline / Table -->
<div class="seg" role="tablist" aria-label="Ansicht">
<button
class="seg-btn"
:class="{active: viewMode === 'timeline'}"
title="Timeline-Ansicht"
role="tab"
:aria-selected="viewMode === 'timeline'"
@click="viewMode = 'timeline'"
>
<v-icon size="16">mdi-timeline-text-outline</v-icon>
</button>
<button
class="seg-btn"
:class="{active: viewMode === 'table'}"
title="Tabellen-Ansicht"
role="tab"
:aria-selected="viewMode === 'table'"
@click="viewMode = 'table'"
>
<v-icon size="16">mdi-table</v-icon>
</button>
</div>
<!-- Actor / Type Filter -->
<div ref="filterWrapRef" class="pop-wrap">
<button
class="icon-btn filter-trigger"
:class="{active: showFilterMenu || activeFilterCount > 0}"
title="Typen filtern"
@click="showFilterMenu = !showFilterMenu"
>
<v-icon size="18">mdi-filter-variant</v-icon>
<span v-if="activeFilterCount > 0" class="badge">{{ activeFilterCount }}</span>
</button>
</div>
<!-- Spalten (nur sinnvoll in Tabelle) -->
<div v-if="viewMode === 'table'" ref="columnWrapRef" class="pop-wrap">
<button
class="icon-btn column-trigger"
:class="{active: showColumnMenu}"
title="Spalten ein-/ausblenden"
@click="showColumnMenu = !showColumnMenu"
>
<v-icon size="18">mdi-table-column</v-icon>
<span v-if="hiddenColCount > 0" class="badge">{{ hiddenColCount }}</span>
</button>
</div>
<div ref="simbriefWrapRef" class="simbrief-wrap">
<button
class="icon-btn"
:class="{active: showSimbrief}"
title="SimBrief"
@click="showSimbrief = !showSimbrief"
>
<v-icon size="18">mdi-cloud-download-outline</v-icon>
</button>
</div>
<button class="icon-btn" :class="{active: showCanvas}"
:title="showCanvas ? 'Canvas ausblenden' : 'Canvas einblenden'"
@click="showCanvas = !showCanvas">
<v-icon size="18">mdi-pen</v-icon>
</button>
<button class="icon-btn" title="Reset" @click="resetAll">
<v-icon size="18">mdi-refresh</v-icon>
</button>
</div>
</div>
<div class="phase-bar" role="tablist">
<button
v-for="p in phases"
:key="p.id"
class="phase-tab"
:class="['accent-' + p.accent, {active: activeStep?.phase.id === p.id}]"
role="tab"
:aria-selected="activeStep?.phase.id === p.id"
@click="jumpToPhase(p.id)"
>
<span class="phase-tab-label">{{ p.title }}</span>
<span class="phase-tab-progress">{{ phaseProgress[p.id]?.done }}/{{ phaseProgress[p.id]?.total }}</span>
</button>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{width: totalProgress + '%'}"/>
</div>
</header>
<main class="body">
<!-- Timeline -->
<section v-if="viewMode === 'timeline'" class="timeline-col">
<div class="timeline">
<div class="timeline-spacer"/>
<template v-for="(phase, pi) in phases" :key="phase.id">
<!-- Phase Banner als nahtloser Abschnitt -->
<div
v-if="phaseHasVisibleStep(phase)"
:class="['phase-banner', 'accent-' + phase.accent, {first: pi === 0}]"
>
<div class="phase-banner-bar"/>
<div class="phase-banner-body">
<div class="phase-eyebrow">Phase {{ pi + 1 }} · {{ phases.length }}</div>
<h2 class="phase-title">{{ phase.title }}</h2>
<div v-if="phase.subtitle" class="phase-sub">{{ phase.subtitle }}</div>
</div>
<div class="phase-progress-mini">
<div class="phase-progress-mini-track">
<div class="phase-progress-mini-fill" :style="{width: phaseProgress[phase.id]?.pct + '%'}"/>
</div>
<span>{{ phaseProgress[phase.id]?.done }}/{{ phaseProgress[phase.id]?.total }}</span>
</div>
</div>
<template v-for="step in phase.steps" :key="step.id">
<!-- Step Block (mit oder ohne Variants) -->
<article
v-if="stepIsVisible(step)"
:data-step-id="step.id"
:class="['step', 'accent-' + phase.accent,
positionClass(filteredSteps.findIndex(a => a.step.id === step.id)),
`actor-${step.actor}`,
{'has-variants': !!step.variants}]"
@click="setActive(step.id)"
>
<div class="step-rail">
<div class="actor-dot">
<v-icon size="18">{{ actorIcon[step.actor] }}</v-icon>
</div>
</div>
<div class="step-body">
<div class="step-meta">
<span class="actor-label">{{ actorLabel[step.actor] }}</span>
<span v-if="step.look" class="look-pill">
<v-icon size="12">mdi-eye-outline</v-icon>
{{ step.look }}
</span>
</div>
<h3 class="step-title">
<RichText :tokens="tokenize(step.label)" :resolve="placeholderValue" :glossary="glossaryByTerm"/>
</h3>
<div v-if="step.callout" class="callout">
<v-icon size="16" class="callout-icon">mdi-radio-handheld</v-icon>
<div class="callout-text">
<RichText :tokens="tokenize(step.callout)" :resolve="placeholderValue"
:glossary="glossaryByTerm"/>
</div>
</div>
<p v-if="step.detail" class="step-detail">
<RichText :tokens="tokenize(step.detail)" :resolve="placeholderValue" :glossary="glossaryByTerm"/>
</p>
<Transition name="why">
<div v-if="expanded[step.id] && step.why" class="why-block">
<div class="why-label">
<v-icon size="14">mdi-lightbulb-on-outline</v-icon>
Warum?
</div>
<p>
<RichText :tokens="tokenize(step.why)" :resolve="placeholderValue" :glossary="glossaryByTerm"/>
</p>
</div>
</Transition>
<div v-if="step.simNote" class="sim-note">
<v-icon size="13">mdi-monitor</v-icon>
<span>{{ step.simNote }}</span>
</div>
<div v-if="step.inputKeys?.length" class="input-row">
<span
v-for="k in step.inputKeys"
:key="k"
class="input-chip"
:class="{filled: !!scratch[k]}"
>
<span class="input-chip-label">{{
scratchFields.find(f => f.key === k)?.label || k
}}</span>
<span v-if="scratch[k]" class="input-chip-value">{{ scratch[k] }}</span>
</span>
</div>
<div v-if="step.variants?.length" class="variant-tabs" @click.stop>
<button
v-for="v in step.variants"
:key="v.id"
class="variant-tab"
:class="{active: (variantSel[step.id] || step.variants[0].id) === v.id}"
@click="selectVariant(step.id, v.id)"
>
{{ v.title }}
</button>
</div>
</div>
</article>
</template>
</template>
<div class="timeline-spacer"/>
</div>
<!-- Fixer Footer: immer an gleicher Position -->
<div v-if="activeStep" class="step-footer" @click.stop>
<button class="btn" :disabled="activeIdx <= 0" @click="prevStep">
<v-icon size="20">mdi-chevron-left</v-icon>
<span class="footer-btn-label">Back</span>
</button>
<button v-if="activeStep.step.why" class="btn" :class="{open: expanded[activeStep.step.id]}"
@click="toggleWhy(activeStep.step.id)">
<v-icon size="18">{{
expanded[activeStep.step.id] ? 'mdi-information' : 'mdi-information-outline'
}}
</v-icon>
<span class="footer-btn-label">Warum</span>
</button>
<button class="btn primary footer-next" :disabled="activeIdx >= filteredSteps.length - 1" @click="nextStep">
<span>Done &amp; Next</span>
<v-icon size="20">mdi-chevron-right</v-icon>
</button>
</div>
</section>
<!-- Tabellen-Ansicht: kompakt -->
<section v-else class="table-col">
<div class="table-scroll">
<table class="steps-table">
<thead>
<tr>
<th v-for="c in visibleColList" :key="c.key" :class="'col-' + c.key">{{ c.label }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, ri) in filteredSteps"
:key="row.step.id"
:data-step-id="row.step.id"
:class="['table-row', 'accent-' + row.phase.accent, `actor-${row.step.actor}`,
{'is-active-row': row.step.id === activeStepId}]"
@click="setActive(row.step.id)"
>
<td v-for="c in visibleColList" :key="c.key" :class="'col-' + c.key">
<template v-if="c.key === 'idx'">{{ ri + 1 }}</template>
<template v-else-if="c.key === 'phase'">
<span class="cell-phase">{{ row.phase.title }}</span>
</template>
<template v-else-if="c.key === 'actor'">
<span class="cell-actor">
<v-icon size="14">{{ actorIcon[row.step.actor] }}</v-icon>
<span class="cell-actor-label">{{ actorLabel[row.step.actor] }}</span>
</span>
</template>
<template v-else-if="c.key === 'label'">
<RichText :tokens="tokenize(row.step.label)" :resolve="placeholderValue"
:glossary="glossaryByTerm"/>
</template>
<template v-else-if="c.key === 'callout'">
<span v-if="row.step.callout" class="cell-callout">
<RichText :tokens="tokenize(row.step.callout)" :resolve="placeholderValue"
:glossary="glossaryByTerm"/>
</span>
<span v-else class="cell-empty"></span>
</template>
<template v-else-if="c.key === 'detail'">
<span v-if="row.step.detail" class="cell-detail">
<RichText :tokens="tokenize(row.step.detail)" :resolve="placeholderValue"
:glossary="glossaryByTerm"/>
</span>
<span v-else class="cell-empty"></span>
</template>
<template v-else-if="c.key === 'inputs'">
<span v-if="row.step.inputKeys?.length" class="cell-inputs">
<span
v-for="k in row.step.inputKeys"
:key="k"
class="cell-chip"
:class="{filled: !!scratch[k]}"
>
{{ scratchFields.find(f => f.key === k)?.label || k }}<span
v-if="scratch[k]">: {{ scratch[k] }}</span>
</span>
</span>
<span v-else class="cell-empty"></span>
</template>
</td>
</tr>
<tr v-if="!filteredSteps.length">
<td :colspan="visibleColList.length" class="empty-state">
Keine Schritte sichtbar Filter prüfen.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer auch in Tabelle -->
<div v-if="activeStep" class="step-footer" @click.stop>
<button class="btn" :disabled="activeIdx <= 0" @click="prevStep">
<v-icon size="20">mdi-chevron-left</v-icon>
<span class="footer-btn-label">Back</span>
</button>
<button v-if="activeStep.step.why" class="btn" :class="{open: expanded[activeStep.step.id]}"
@click="toggleWhy(activeStep.step.id)">
<v-icon size="18">{{
expanded[activeStep.step.id] ? 'mdi-information' : 'mdi-information-outline'
}}
</v-icon>
<span class="footer-btn-label">Warum</span>
</button>
<button class="btn primary footer-next" :disabled="activeIdx >= filteredSteps.length - 1" @click="nextStep">
<span>Done &amp; Next</span>
<v-icon size="20">mdi-chevron-right</v-icon>
</button>
</div>
</section>
<!-- Resize Handle -->
<div
v-if="showCanvas"
class="resize-handle"
@pointerdown="onResizeStart"
@pointermove="onResizeMove"
@pointerup="onResizeEnd"
@pointercancel="onResizeEnd"
/>
<!-- Aside (Canvas only, immer unten) -->
<aside v-if="showCanvas" class="aside" :style="{height: asideHeight + 'px'}">
<div class="canvas-wrap">
<div class="canvas-toolbar">
<button class="canvas-btn" @click="clearCanvas">
<v-icon size="14">mdi-eraser</v-icon>
Wipe
</button>
<button class="canvas-btn" @click="cleanfeedCanvas"
title="Inhalt nach oben schieben, unten leeren Platz nachrutschen">
<v-icon size="14">mdi-arrow-up-bold-box-outline</v-icon>
Cleanfeed
</button>
</div>
<div ref="canvasWrapRef" class="canvas-scroll">
<canvas ref="canvasRef" class="canvas"/>
</div>
</div>
</aside>
</main>
<!-- SimBrief Popover (Teleport zum body um stacking-context zu umgehen) -->
<Teleport to="body">
<Transition name="pop">
<div
v-if="showSimbrief"
class="simbrief-pop-fixed"
:style="simbriefPopPos"
@click.stop
>
<div class="simbrief-pop-title">SimBrief Import</div>
<input
v-model="simbriefUser"
placeholder="SimBrief User / ID"
class="simbrief-input"
@keyup.enter="importSimbrief"
/>
<button
class="simbrief-btn"
:disabled="simbriefLoading || !simbriefUser.trim()"
@click="importSimbrief"
>
<v-icon v-if="simbriefLoading" size="14">mdi-loading mdi-spin</v-icon>
<span v-else>Import</span>
</button>
<div v-if="simbriefError" class="simbrief-pop-err">{{ simbriefError }}</div>
</div>
</Transition>
</Teleport>
<!-- Typen-Filter Popover -->
<Teleport to="body">
<Transition name="pop">
<div
v-if="showFilterMenu"
class="menu-pop filter-pop"
:style="filterPopPos"
@click.stop
>
<div class="menu-pop-title">Typen anzeigen</div>
<label v-for="a in ALL_ACTORS" :key="a" :class="['menu-row', `actor-${a}`]">
<input type="checkbox" :checked="actorFilter[a]" @change="toggleActorFilter(a)"/>
<v-icon size="14" class="menu-icon">{{ actorIcon[a] }}</v-icon>
<span class="menu-label">{{ actorLabel[a] }}</span>
</label>
<div class="menu-pop-foot">
<button class="menu-link" @click="setAllActors(true)">Alle</button>
<span class="menu-dot">·</span>
<button class="menu-link" @click="onlyActor('pilot')">Nur Pilot</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Spalten Popover -->
<Teleport to="body">
<Transition name="pop">
<div
v-if="showColumnMenu"
class="menu-pop column-pop"
:style="columnPopPos"
@click.stop
>
<div class="menu-pop-title">Spalten</div>
<label v-for="c in ALL_COLS" :key="c.key" class="menu-row">
<input type="checkbox" :checked="visibleCols[c.key]" @change="toggleColumn(c.key)"/>
<span class="menu-label">{{ c.label }}</span>
</label>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.copilot {
--bg: #0a0f1d;
--bg2: #080d18;
--accent: #22d3ee;
--accent2: #0ea5e9;
--text: #f1f5f9;
--t2: rgba(241, 245, 249, .82);
--t3: rgba(241, 245, 249, .58);
--t4: rgba(241, 245, 249, .36);
--border: rgba(255, 255, 255, .07);
--border-strong: rgba(255, 255, 255, .14);
--surface: rgba(255, 255, 255, .025);
--surface-2: rgba(255, 255, 255, .05);
min-height: 100vh;
background: var(--bg);
color: var(--text);
padding-top: env(safe-area-inset-top);
/* Dezenter, ruhiger Farbverlauf statt dominante Glow-Spots */
background-image: radial-gradient(1100px 560px at 82% -16%, color-mix(in srgb, var(--accent) 7%, transparent), transparent 65%),
radial-gradient(720px 380px at 6% -8%, color-mix(in srgb, var(--accent2) 6%, transparent), transparent 65%);
}
.accent-cyan {
--pa: #22d3ee;
--pa-soft: rgba(34, 211, 238, .12);
}
.accent-sky {
--pa: #38bdf8;
--pa-soft: rgba(56, 189, 248, .12);
}
.accent-indigo {
--pa: #818cf8;
--pa-soft: rgba(129, 140, 248, .12);
}
.accent-emerald {
--pa: #34d399;
--pa-soft: rgba(52, 211, 153, .12);
}
.accent-amber {
--pa: #fbbf24;
--pa-soft: rgba(251, 191, 36, .12);
}
.accent-rose {
--pa: #fb7185;
--pa-soft: rgba(251, 113, 133, .12);
}
/* Actor accents */
.actor-pilot {
--aa: #22d3ee;
}
.actor-pf {
--aa: #34d399;
}
.actor-pm {
--aa: #a78bfa;
}
.actor-atc {
--aa: #fbbf24;
}
.actor-cabin {
--aa: #fb7185;
}
.actor-system {
--aa: #94a3b8;
}
/* HUD */
.hud {
position: sticky;
top: 0;
z-index: 200;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.hud-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
max-width: 1600px;
margin: 0 auto;
}
.hud-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.hud-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
flex-shrink: 0;
transition: all .2s;
}
.hud-logo:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
.hud-divider {
width: 1px;
height: 22px;
background: var(--border);
}
.hud-brand {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 1;
min-width: 0;
}
.brand-name {
font-weight: 600;
font-size: 14px;
}
.brand-mode {
font-size: 11.5px;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hud-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.simbrief-wrap {
position: relative;
}
.simbrief-pop-fixed {
position: fixed;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
width: 260px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(11, 16, 32, .96);
color: #fff;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 16px 36px rgba(0, 0, 0, .55);
}
.simbrief-pop-title {
font-size: 11px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--accent);
}
.simbrief-pop-err {
font-size: 12px;
color: #fecaca;
}
.simbrief-input {
height: 36px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, .25);
outline: none;
color: var(--text);
font-size: 13px;
}
.simbrief-input::placeholder {
color: var(--t4);
}
.simbrief-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
border-radius: 8px;
background: var(--accent);
color: #001218;
border: none;
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
.simbrief-btn:disabled {
opacity: .4;
cursor: not-allowed;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--t2);
cursor: pointer;
transition: all .2s;
flex-shrink: 0;
}
.icon-btn:hover {
background: var(--surface-2);
color: var(--text);
}
.icon-btn.active {
background: color-mix(in srgb, var(--accent) 16%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
.pop-enter-active, .pop-leave-active {
transition: opacity .15s, transform .15s;
}
.pop-enter-from, .pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.phase-bar {
display: flex;
gap: 0;
overflow-x: auto;
padding: 0 12px;
max-width: 1600px;
margin: 0 auto;
scrollbar-width: none;
border-top: 1px solid var(--border);
}
.phase-bar::-webkit-scrollbar {
display: none;
}
.phase-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: none;
background: transparent;
color: var(--t3);
font-size: 12.5px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: color .15s;
}
.phase-tab::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
bottom: 0;
height: 2px;
background: transparent;
border-radius: 2px 2px 0 0;
transition: background .15s;
}
.phase-tab:hover {
color: var(--text);
}
.phase-tab.active {
color: var(--pa);
}
.phase-tab.active::after {
background: var(--pa);
}
.phase-tab-progress {
font-size: 10.5px;
opacity: .7;
font-variant-numeric: tabular-nums;
}
.progress-track {
height: 2px;
background: var(--surface);
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width .3s ease;
}
.error-banner {
background: color-mix(in srgb, #ef4444 14%, transparent);
color: #fecaca;
padding: 6px 14px;
font-size: 12.5px;
text-align: center;
}
/* Body — Flex-Spalte: Timeline + Canvas immer unten */
.body {
display: flex;
flex-direction: column;
max-width: 1600px;
margin: 0 auto;
height: calc(100dvh - 88px - env(safe-area-inset-top));
overflow: hidden;
}
@media (min-width: 1100px) {
.body {
padding: 12px 12px 0;
height: calc(100dvh - 96px - env(safe-area-inset-top));
}
}
.timeline-col {
min-width: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* Timeline = große Scroll-Snap-Liste, nahtlose Blöcke */
.timeline {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
overscroll-behavior: contain;
}
.timeline::-webkit-scrollbar {
width: 0;
}
.timeline-spacer {
height: 30vh;
}
/* Phase Banner nahtlos, kein Card-Look */
.phase-banner {
display: flex;
align-items: stretch;
gap: 14px;
padding: 22px 18px 16px;
background: linear-gradient(180deg, color-mix(in srgb, var(--pa) 6%, transparent), transparent);
border-top: 1px solid color-mix(in srgb, var(--pa) 18%, transparent);
scroll-snap-align: start;
}
.phase-banner.first {
border-top: none;
}
.phase-banner-bar {
width: 3px;
border-radius: 3px;
background: var(--pa);
}
.phase-banner-body {
flex: 1;
min-width: 0;
}
.phase-eyebrow {
font-size: 10.5px;
font-weight: 700;
letter-spacing: .22em;
text-transform: uppercase;
color: var(--pa);
margin-bottom: 2px;
}
.phase-title {
font-size: 22px;
font-weight: 700;
line-height: 1.15;
}
.phase-sub {
margin-top: 2px;
font-size: 13px;
color: var(--t3);
}
.phase-progress-mini {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 4px;
font-size: 11.5px;
color: var(--t3);
font-variant-numeric: tabular-nums;
}
.phase-progress-mini-track {
width: 72px;
height: 3px;
background: var(--surface-2);
border-radius: 999px;
overflow: hidden;
}
.phase-progress-mini-fill {
height: 100%;
background: var(--pa);
transition: width .3s;
}
/* STEP BLOCK nahtlos, ohne Card */
.step {
position: relative;
display: grid;
grid-template-columns: 60px minmax(0, 1fr);
align-items: stretch;
gap: 0;
padding: 16px 18px;
border-top: 1px solid var(--border);
cursor: pointer;
scroll-snap-align: center;
transition: opacity .25s, padding .2s, background .25s, transform .2s;
}
.step:first-of-type {
border-top: none;
}
/* Position-States */
.step.is-past {
opacity: .42;
}
.step.is-past.is-near {
opacity: .55;
}
.step.is-future {
opacity: .58;
}
.step.is-future.is-near {
opacity: .78;
}
.step.is-active {
opacity: 1;
background: linear-gradient(180deg,
color-mix(in srgb, var(--pa) 5%, transparent),
color-mix(in srgb, var(--pa) 2%, transparent) 60%,
transparent);
padding: 24px 18px 22px;
}
/* Linker Rail Actor-Indicator + Linie */
.step-rail {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 60px;
}
.step-rail::before {
content: '';
position: absolute;
top: -16px;
bottom: -16px;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: var(--border);
}
.step:first-of-type .step-rail::before {
top: 0;
}
.step:last-of-type .step-rail::before {
bottom: 0;
}
.actor-dot {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--bg);
background: color-mix(in srgb, var(--aa) 22%, var(--bg));
color: var(--aa);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--aa) 40%, transparent);
transition: transform .25s, box-shadow .25s;
}
.step.is-active .actor-dot {
background: var(--aa);
color: #001218;
transform: scale(1.14);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--aa) 22%, transparent);
}
.step.is-past .actor-dot {
background: color-mix(in srgb, var(--aa) 12%, var(--bg));
color: color-mix(in srgb, var(--aa) 60%, white 0%);
}
.step.is-past .actor-dot::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(0, 0, 0, .25);
}
/* Step Body */
.step-body {
min-width: 0;
padding-left: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.step-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.actor-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--aa);
}
.look-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--t3);
font-size: 11.5px;
}
.step-title {
font-size: 15.5px;
font-weight: 600;
line-height: 1.35;
color: var(--t2);
transition: font-size .2s, color .2s;
}
.step.is-active .step-title {
font-size: 19px;
color: var(--text);
}
.callout {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-left: 3px solid var(--accent);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13.5px;
line-height: 1.55;
color: #cffafe;
}
.callout-icon {
color: var(--accent);
flex-shrink: 0;
margin-top: 2px;
}
.callout-text {
flex: 1;
min-width: 0;
}
.step-detail {
color: var(--t2);
font-size: 14px;
line-height: 1.55;
}
.sim-note {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 7px;
background: color-mix(in srgb, #a78bfa 12%, transparent);
border-left: 2px solid #a78bfa;
color: #ddd6fe;
font-size: 12px;
width: fit-content;
}
.input-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.input-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 7px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 12px;
}
.input-chip-label {
color: var(--t3);
font-weight: 600;
font-size: 10.5px;
letter-spacing: .08em;
text-transform: uppercase;
}
.input-chip-value {
color: var(--t4);
font-variant-numeric: tabular-nums;
}
.input-chip.filled {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.input-chip.filled .input-chip-label {
color: var(--accent);
}
.input-chip.filled .input-chip-value {
color: #cffafe;
}
/* Why-Block */
.why-block {
padding: 12px 14px;
border-radius: 10px;
background: color-mix(in srgb, #fbbf24 8%, transparent);
border-left: 3px solid #fbbf24;
}
.why-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
letter-spacing: .18em;
text-transform: uppercase;
font-weight: 700;
color: #fcd34d;
margin-bottom: 4px;
}
.why-block p {
font-size: 13.5px;
line-height: 1.55;
color: var(--t2);
}
.why-enter-active, .why-leave-active {
transition: opacity .2s, max-height .25s ease;
overflow: hidden;
}
.why-enter-from, .why-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
.why-enter-to, .why-leave-from {
opacity: 1;
max-height: 600px;
}
/* Variant Tabs */
.variant-tabs {
display: flex;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
overflow-x: auto;
}
.variant-tab {
flex: 1;
min-width: max-content;
padding: 9px 14px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--t3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all .2s;
}
.variant-tab:hover {
color: var(--text);
background: var(--surface-2);
}
.variant-tab.active {
background: color-mix(in srgb, var(--pa) 18%, transparent);
color: var(--pa);
}
/* Fixer Step-Footer */
.step-footer {
display: flex;
gap: 8px;
padding: 10px 12px;
padding-bottom: calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 96%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
flex-shrink: 0;
align-items: stretch;
}
.step-footer .btn {
border-radius: 12px;
height: 48px;
}
.footer-next {
flex: 1;
justify-content: center;
}
@media (max-width: 480px) {
.footer-btn-label {
display: none;
}
}
/* Step-Footer Button Overrides */
.step-footer .btn:disabled {
opacity: .35;
cursor: not-allowed;
}
.step-footer .btn.open {
background: color-mix(in srgb, #fbbf24 18%, transparent);
border-color: color-mix(in srgb, #fbbf24 40%, transparent);
color: #fcd34d;
}
/* Resize Handle */
.resize-handle {
height: 8px;
cursor: row-resize;
flex-shrink: 0;
background: transparent;
position: relative;
z-index: 10;
touch-action: none;
}
.resize-handle::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 3px;
border-radius: 999px;
background: var(--border-strong);
transition: background .15s;
}
.resize-handle:hover::after {
background: var(--accent);
}
/* Aside — immer unten */
.aside {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
flex-shrink: 0;
border-top: 1px solid var(--border);
background: var(--bg2);
}
@media (min-width: 1100px) {
.aside {
border-radius: 14px 14px 0 0;
border: 1px solid var(--border);
border-bottom: none;
background: var(--surface);
}
}
.canvas-wrap {
position: relative;
flex: 1;
min-height: 0;
}
.canvas-scroll {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 8px;
}
.canvas-toolbar {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
display: flex;
align-items: center;
gap: 6px;
}
.canvas-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: rgba(11, 16, 32, .85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--text);
font-size: 12px;
cursor: pointer;
transition: all .15s;
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
}
.canvas-btn:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
color: var(--text);
}
.canvas {
display: block;
width: 100%;
flex-shrink: 0;
height: 1400px;
border-radius: 10px;
background: rgba(0, 0, 0, .35);
border: 1px solid var(--border);
cursor: crosshair;
touch-action: none;
}
/* RichText */
:deep(.rt-term) {
display: inline-block;
padding: 0 5px;
border-radius: 5px;
background: color-mix(in srgb, #fbbf24 14%, transparent);
border-bottom: 1px dashed color-mix(in srgb, #fbbf24 50%, transparent);
color: #fde68a;
cursor: help;
font-weight: 600;
transition: background .15s;
}
:deep(.rt-term:hover) {
background: color-mix(in srgb, #fbbf24 22%, transparent);
}
:deep(.rt-placeholder) {
display: inline-block;
padding: 0 6px;
margin: 0 1px;
border-radius: 5px;
background: var(--surface);
border: 1px dashed var(--border-strong);
color: var(--t4);
font-style: italic;
font-size: .92em;
}
:deep(.rt-placeholder.filled) {
background: color-mix(in srgb, #22d3ee 14%, transparent);
border: 1px solid color-mix(in srgb, #22d3ee 38%, transparent);
color: #cffafe;
font-style: normal;
}
:deep(.rt-term-popover) {
max-width: 320px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent);
background: color-mix(in srgb, #0b1020 95%, transparent);
backdrop-filter: blur(8px);
color: var(--text);
box-shadow: 0 24px 48px rgba(0, 0, 0, .5);
}
:deep(.rt-term-head) {
font-weight: 700;
font-size: 13px;
letter-spacing: .08em;
color: #fde68a;
margin-bottom: 4px;
}
:deep(.rt-term-short) {
font-size: 13px;
color: var(--t2);
line-height: 1.45;
}
:deep(.rt-term-long) {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--t3);
line-height: 1.5;
}
/* ============================================================
Segmented Button Group (View Toggle)
============================================================ */
.seg {
display: inline-flex;
align-items: center;
padding: 2px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.seg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--t3);
cursor: pointer;
transition: background .15s, color .15s;
}
.seg-btn:hover {
color: var(--text);
}
.seg-btn.active {
background: var(--surface-2);
color: var(--text);
}
/* Badge auf Icon-Buttons */
.icon-btn {
position: relative;
}
.badge {
position: absolute;
top: -3px;
right: -3px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: var(--accent);
color: #001218;
font-size: 9.5px;
font-weight: 700;
line-height: 14px;
text-align: center;
}
/* ============================================================
Generisches Menu-Popover (Filter, Spalten)
============================================================ */
.pop-wrap {
position: relative;
}
.menu-pop {
position: fixed;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
min-width: 200px;
border-radius: 10px;
border: 1px solid var(--border-strong);
background: rgba(11, 16, 32, .96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 12px 28px rgba(0, 0, 0, .5);
}
.menu-pop-title {
padding: 4px 6px 6px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--t3);
}
.menu-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--t2);
transition: background .12s, color .12s;
}
.menu-row:hover {
background: var(--surface-2);
color: var(--text);
}
.menu-row input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
margin: 0;
}
.menu-icon {
color: var(--aa, var(--t3));
flex-shrink: 0;
}
.menu-label {
flex: 1;
}
.menu-pop-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 6px 6px 2px;
border-top: 1px solid var(--border);
font-size: 11.5px;
}
.menu-link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
padding: 2px 4px;
font-size: 11.5px;
}
.menu-link:hover {
text-decoration: underline;
}
.menu-dot {
color: var(--t4);
}
/* ============================================================
Tabellen-Ansicht
============================================================ */
.table-col {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.table-scroll {
flex: 1;
overflow: auto;
padding: 8px 4px 8px;
}
.steps-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12.5px;
}
.steps-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 8px 10px;
text-align: left;
font-size: 10.5px;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--t3);
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.steps-table tbody td {
padding: 8px 10px;
vertical-align: top;
border-bottom: 1px solid var(--border);
color: var(--t2);
line-height: 1.45;
}
.table-row {
cursor: pointer;
transition: background .12s;
}
.table-row:hover {
background: var(--surface);
}
.table-row.is-active-row {
background: color-mix(in srgb, var(--pa, var(--accent)) 10%, transparent);
}
.table-row.is-active-row td {
border-bottom-color: color-mix(in srgb, var(--pa, var(--accent)) 30%, var(--border));
}
.table-row.is-active-row td:first-child {
box-shadow: inset 3px 0 0 var(--pa, var(--accent));
}
.col-idx {
width: 38px;
color: var(--t4);
font-variant-numeric: tabular-nums;
}
.col-phase {
width: 130px;
}
.col-actor {
width: 140px;
}
.col-callout {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
}
.cell-phase {
display: inline-block;
padding: 2px 7px;
border-radius: 5px;
background: var(--pa-soft, var(--surface));
color: var(--pa, var(--text));
font-size: 11px;
font-weight: 600;
}
.cell-actor {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--aa, var(--t3));
}
.cell-actor-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
}
.cell-callout {
color: #cffafe;
}
.cell-detail {
color: var(--t3);
font-size: 12px;
}
.cell-empty {
color: var(--t4);
font-size: 11px;
}
.cell-inputs {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.cell-chip {
display: inline-flex;
padding: 1px 6px;
border-radius: 5px;
background: var(--surface);
border: 1px solid var(--border);
font-size: 10.5px;
color: var(--t3);
}
.cell-chip.filled {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
color: #cffafe;
}
.empty-state {
text-align: center;
padding: 40px 12px;
color: var(--t3);
font-size: 13px;
}
@media (max-width: 720px) {
.col-phase, .col-detail {
display: none;
}
.steps-table {
font-size: 12px;
}
.col-actor {
width: 38px;
}
.cell-actor-label {
display: none;
}
}
</style>
<style>
.cookie-manage-button {
display: none;
}
.rt-term-popover {
background: #0C1B2C;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(251, 191, 36, .30);
box-shadow: 0 24px 48px rgba(0, 0, 0, .5);
}
</style>