diff --git a/app/pages/copilot.vue b/app/pages/copilot.vue index d7badac..dbab93b 100644 --- a/app/pages/copilot.vue +++ b/app/pages/copilot.vue @@ -5,55 +5,55 @@ import {a320Profile, glossary, scratchFields, type SopPhase, type SopStep} from // 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).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) - }), - ) - }, + 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).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'}], + 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.v4' @@ -75,379 +75,379 @@ const simbriefWrapRef = ref(null) const simbriefPopPos = ref>({}) 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', - } - }) - } + 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') + 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 + 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 + 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 + 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 = { - 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 '' + const map: Record = { + 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('/api/copilot/simbrief', {params}) - const map: Record = { - 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 + 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('/api/copilot/simbrief', {params}) + const map: Record = { + 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) - } + 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 + } + 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 + 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 }) const activeIdx = computed(() => allSteps.value.findIndex(s => s.step.id === activeStepId.value)) const activeStep = computed(() => allSteps.value[activeIdx.value]) const phaseProgress = computed(() => { - const ai = activeIdx.value - const out: Record = {} - for (const p of phases.value) { - const steps = stepsForPhase(p) - const total = steps.length - let done = 0 - for (const s of steps) { - const idx = allSteps.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} + const ai = activeIdx.value + const out: Record = {} + for (const p of phases.value) { + const steps = stepsForPhase(p) + const total = steps.length + let done = 0 + for (const s of steps) { + const idx = allSteps.value.findIndex(a => a.step.id === s.id) + if (idx >= 0 && idx < ai) done++ } - return out + out[p.id] = {done, total, pct: total ? Math.round(done / total * 100) : 0} + } + return out }) const totalProgress = computed(() => { - const total = allSteps.value.length - if (!total || activeIdx.value < 0) return 0 - return Math.round((activeIdx.value / total) * 100) + const total = allSteps.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' + 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' } let scrollLock = false function setActive(id: string) { - activeStepId.value = id - nextTick(() => scrollToStep(id)) + activeStepId.value = id + nextTick(() => scrollToStep(id)) } function nextStep() { - if (activeIdx.value < 0 || activeIdx.value >= allSteps.value.length - 1) return - activeStepId.value = allSteps.value[activeIdx.value + 1].step.id - nextTick(() => scrollToStep(activeStepId.value!)) + if (activeIdx.value < 0 || activeIdx.value >= allSteps.value.length - 1) return + activeStepId.value = allSteps.value[activeIdx.value + 1].step.id + nextTick(() => scrollToStep(activeStepId.value!)) } function prevStep() { - if (activeIdx.value <= 0) return - activeStepId.value = allSteps.value[activeIdx.value - 1].step.id - nextTick(() => scrollToStep(activeStepId.value!)) + if (activeIdx.value <= 0) return + activeStepId.value = allSteps.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 - if (!el) return - scrollLock = true - el.scrollIntoView({behavior: 'smooth', block: 'center'}) - setTimeout(() => { - scrollLock = false - }, 600) + const el = document.querySelector(`[data-step-id="${id}"]`) as HTMLElement | null + if (!el) return + scrollLock = true + el.scrollIntoView({behavior: 'smooth', block: 'center'}) + setTimeout(() => { + scrollLock = false + }, 600) } function toggleWhy(id: string) { - scrollLock = true - expanded.value[id] = !expanded.value[id] - nextTick(() => { - scrollToStep(id) - }) + scrollLock = true + expanded.value[id] = !expanded.value[id] + nextTick(() => { + scrollToStep(id) + }) } function selectVariant(stepId: string, variantId: string) { - scrollLock = true - variantSel.value[stepId] = variantId - nextTick(() => scrollToStep(stepId)) + scrollLock = true + 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 + 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 || !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) + 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) + 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 = phases.value[0]?.steps[0]?.id || null - canvasImage.value = '' - clearCanvas() - persist() + if (!confirm('Alle Eingaben, Fortschritt & Canvas löschen?')) return + scratch.value = {} + variantSel.value = {} + expanded.value = {} + activeStepId.value = phases.value[0]?.steps[0]?.id || null + canvasImage.value = '' + clearCanvas() + persist() } // Persistence const canvasImage = ref('') 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, - })) + 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, + })) } watch([scratch, variantSel, activeStepId, showCanvas, simbriefUser, asideHeight], 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) - } + 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() - const root = document.querySelector('.timeline') as HTMLElement | null - if (!root) return - observer = new IntersectionObserver((entries) => { - 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) 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 (typeof IntersectionObserver === 'undefined') return + observer?.disconnect() + const root = document.querySelector('.timeline') as HTMLElement | null + if (!root) return + observer = new IntersectionObserver((entries) => { + 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) 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)) } watch(allSteps, () => 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 - } catch { - } - } + 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 + } catch { + } } - if (!activeStepId.value && allSteps.value.length) activeStepId.value = allSteps.value[0].step.id - nextTick(() => { - setupObserver() - scrollToStep(activeStepId.value!) - setupCanvas() - }) - window.addEventListener('keydown', onKey) + } + if (!activeStepId.value && allSteps.value.length) activeStepId.value = allSteps.value[0].step.id + nextTick(() => { + setupObserver() + scrollToStep(activeStepId.value!) + setupCanvas() + }) + window.addEventListener('keydown', onKey) }) onUnmounted(() => { - observer?.disconnect() - window.removeEventListener('keydown', onKey) + observer?.disconnect() + window.removeEventListener('keydown', onKey) }) // Resize Handle für Scratchpad @@ -456,23 +456,23 @@ 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) + 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)) + 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() + if (!resizing) return + resizing = false + ;(e.target as HTMLElement).releasePointerCapture(e.pointerId) + persist() } // Canvas @@ -483,1383 +483,1407 @@ 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 + 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 { - } + 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 - } + 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) + } + 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) + 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) + 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 + 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() + 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 = { - pilot: 'mdi-radio-handheld', - pf: 'mdi-airplane', - pm: 'mdi-eye-check', - atc: 'mdi-tower-fire', - cabin: 'mdi-account-group', - system: 'mdi-cog-sync-outline', + 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 = { - pilot: 'Funkspruch · DU', - pf: 'Pilot Flying', - pm: 'Pilot Monitoring', - atc: 'ATC sagt', - cabin: 'Cabin Crew', - system: 'Flugzeug', + pilot: 'Funkspruch · DU', + pf: 'Pilot Flying', + pm: 'Pilot Monitoring', + atc: 'ATC sagt', + cabin: 'Cabin Crew', + system: 'Flugzeug', } + +
+
+ + + + + +
+ + + + + + + + +
+
SimBrief Import
+ + +
{{ simbriefError }}
+
+
+
+
+