diff --git a/app/pages/copilot.vue b/app/pages/copilot.vue index dbab93b..b74ae68 100644 --- a/app/pages/copilot.vue +++ b/app/pages/copilot.vue @@ -56,7 +56,7 @@ useHead({ link: [{rel: 'manifest', href: '/copilot.webmanifest'}], }) -const STORAGE_KEY = 'opensquawk.copilot.v4' +const STORAGE_KEY = 'opensquawk.copilot.v5' const activeProfile = ref(a320Profile) const phases = computed(() => activeProfile.value.phases) @@ -74,6 +74,102 @@ const asideHeight = ref(280) const simbriefWrapRef = ref(null) const simbriefPopPos = ref>({}) +// View / Filter / Columns +type ViewMode = 'timeline' | 'table' +const viewMode = ref('timeline') + +const ALL_ACTORS = ['pilot', 'pf', 'pm', 'atc', 'cabin', 'system'] as const +type ActorKey = typeof ALL_ACTORS[number] +const actorFilter = ref>({ + 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>({ + 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(null) +const columnWrapRef = ref(null) +const filterPopPos = ref>({}) +const columnPopPos = ref>({}) + +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(() => { @@ -231,18 +327,21 @@ const allSteps = computed(() => { return out }) -const activeIdx = computed(() => allSteps.value.findIndex(s => s.step.id === activeStepId.value)) -const activeStep = computed(() => allSteps.value[activeIdx.value]) +// 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 = {} for (const p of phases.value) { - const steps = stepsForPhase(p) + 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 = allSteps.value.findIndex(a => a.step.id === s.id) + 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} @@ -251,7 +350,7 @@ const phaseProgress = computed(() => { }) const totalProgress = computed(() => { - const total = allSteps.value.length + const total = filteredSteps.value.length if (!total || activeIdx.value < 0) return 0 return Math.round((activeIdx.value / total) * 100) }) @@ -264,7 +363,23 @@ function positionClass(idx: number): string { 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 | 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 @@ -272,29 +387,49 @@ function setActive(id: string) { } function nextStep() { - if (activeIdx.value < 0 || activeIdx.value >= allSteps.value.length - 1) return - activeStepId.value = allSteps.value[activeIdx.value + 1].step.id + 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 = allSteps.value[activeIdx.value - 1].step.id + activeStepId.value = filteredSteps.value[activeIdx.value - 1].step.id nextTick(() => scrollToStep(activeStepId.value!)) } function scrollToStep(id: string) { - const el = document.querySelector(`[data-step-id="${id}"]`) as HTMLElement | null + const el = document.querySelector(`.timeline [data-step-id="${id}"]`) as HTMLElement | null if (!el) return - scrollLock = true + pendingScrollId = id + lockScroll(750) el.scrollIntoView({behavior: 'smooth', block: 'center'}) - setTimeout(() => { - scrollLock = false - }, 600) +} + +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) { - scrollLock = true + lockScroll() expanded.value[id] = !expanded.value[id] nextTick(() => { scrollToStep(id) @@ -302,7 +437,7 @@ function toggleWhy(id: string) { } function selectVariant(stepId: string, variantId: string) { - scrollLock = true + lockScroll() variantSel.value[stepId] = variantId nextTick(() => scrollToStep(stepId)) } @@ -346,7 +481,7 @@ function resetAll() { scratch.value = {} variantSel.value = {} expanded.value = {} - activeStepId.value = phases.value[0]?.steps[0]?.id || null + activeStepId.value = filteredSteps.value[0]?.step.id || phases.value[0]?.steps[0]?.id || null canvasImage.value = '' clearCanvas() persist() @@ -365,10 +500,13 @@ function persist() { 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], persist, {deep: true}) +watch([scratch, variantSel, activeStepId, showCanvas, simbriefUser, asideHeight, viewMode, actorFilter, visibleCols], persist, {deep: true}) // Keyboard function onKey(e: KeyboardEvent) { @@ -400,9 +538,11 @@ 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) { @@ -412,12 +552,12 @@ function setupObserver() { if (!best || e.intersectionRatio > best.ratio) best = {id, ratio: e.intersectionRatio} } } - if (best) activeStepId.value = best.id - }, {root, rootMargin: '-40% 0px -40% 0px', threshold: [0, 0.1, 0.5, 1]}) - document.querySelectorAll('[data-step-id]').forEach(el => observer?.observe(el)) + 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(allSteps, () => nextTick(setupObserver)) +watch(filteredSteps, () => nextTick(setupObserver)) onMounted(() => { if (typeof localStorage !== 'undefined') { @@ -432,22 +572,71 @@ onMounted(() => { 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() - scrollToStep(activeStepId.value!) + 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 @@ -641,6 +830,56 @@ const actorLabel: Record = {
+ +
+ + +
+ + +
+ +
+ + +
+ +
+