mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
2590 lines
68 KiB
Vue
2590 lines
68 KiB
Vue
<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 & 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 & 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>
|