diff --git a/app/components/editor/DecisionNodeCanvas.vue b/app/components/editor/DecisionNodeCanvas.vue index 1926b61..9183a4b 100644 --- a/app/components/editor/DecisionNodeCanvas.vue +++ b/app/components/editor/DecisionNodeCanvas.vue @@ -104,6 +104,10 @@ import type { const NODE_WIDTH = 280 const NODE_HEIGHT = 160 +const WORKSPACE_PADDING = 800 +const MIN_WORKSPACE_WIDTH = 4000 +const MIN_WORKSPACE_HEIGHT = 2800 +const DRAG_MARGIN = 200 interface CanvasNodePreviewTransition { key: string @@ -202,9 +206,20 @@ const preparedNodes = computed(() => ) const canvasBounds = computed(() => { - const maxX = Math.max(...preparedNodes.value.map((node) => node.layout.x + node.width), NODE_WIDTH * 3) - const maxY = Math.max(...preparedNodes.value.map((node) => node.layout.y + node.height), NODE_HEIGHT * 2) - return { width: maxX + 600, height: maxY + 400 } + const defaultWidth = MIN_WORKSPACE_WIDTH - WORKSPACE_PADDING + const defaultHeight = MIN_WORKSPACE_HEIGHT - WORKSPACE_PADDING + const maxX = Math.max( + ...preparedNodes.value.map((node) => node.layout.x + node.width), + defaultWidth + ) + const maxY = Math.max( + ...preparedNodes.value.map((node) => node.layout.y + node.height), + defaultHeight + ) + return { + width: Math.max(maxX + WORKSPACE_PADDING, MIN_WORKSPACE_WIDTH), + height: Math.max(maxY + WORKSPACE_PADDING, MIN_WORKSPACE_HEIGHT), + } }) interface EdgeDefinition { @@ -358,6 +373,8 @@ interface DragState { nodeId: string offsetX: number offsetY: number + width: number + height: number } let dragState: DragState | null = null @@ -373,6 +390,8 @@ function onNodePointerDown(event: PointerEvent, node: ReturnType
-
- - -
-
-
-
-

{{ flowForm.name || 'Kein Flow ausgewählt' }}

- {{ flowDetail.flow.slug }} + + {{ flowDetail.flow.slug }} + + + Speichern + + + Auto-Layout +
-

- {{ flowDetail ? 'Start: ' + flowDetail.flow.startState : 'Wähle einen Flow aus der Liste' }} -

-
- - Flow speichern - - - Auto-Layout - -
-
+ +
= { system: '#a855f7', } +const roleIcons: Record = { + pilot: 'mdi-account', + atc: 'mdi-radar', + system: 'mdi-robot', +} + const telemetryParameters = [ 'altitude_ft', 'speed_kts', @@ -626,39 +716,6 @@ const createFlowLoading = ref(false) const snackbar = reactive({ show: false, color: 'cyan', text: '' }) -const formatRelative = (iso: string) => { - const target = new Date(iso) - if (Number.isNaN(target.getTime())) return iso - const diffMs = target.getTime() - Date.now() - const diffSeconds = Math.round(diffMs / 1000) - const rtf = new Intl.RelativeTimeFormat('de', { numeric: 'auto' }) - const divisions = [ - { amount: 60, unit: 'second' }, - { amount: 60, unit: 'minute' }, - { amount: 24, unit: 'hour' }, - { amount: 7, unit: 'day' }, - { amount: 4.34524, unit: 'week' }, - { amount: 12, unit: 'month' }, - { amount: Number.POSITIVE_INFINITY, unit: 'year' }, - ] as const - - let duration = diffSeconds - let unit: Intl.RelativeTimeFormatUnit = 'second' - - for (const division of divisions) { - if (Math.abs(duration) < division.amount) { - unit = division.unit as Intl.RelativeTimeFormatUnit - break - } - duration /= division.amount - } - - if (Number.isFinite(duration)) { - return rtf.format(Math.round(duration), unit) - } - return target.toLocaleString('de-DE') -} - const filteredFlows = computed(() => { const query = flowSearch.value.trim().toLowerCase() if (!query) return flows.value @@ -679,7 +736,7 @@ const phaseFilterOptions = computed(() => { return Array.from(phases) }) -const sidebarNodes = computed(() => { +const nodeSelectorItems = computed(() => { if (!flowDetail.value) return [] const query = nodeFilter.search.trim().toLowerCase() const role = nodeFilter.role @@ -696,6 +753,7 @@ const sidebarNodes = computed(() => { summary: node.summary, phase: node.phase, role: node.role, + icon: node.layout?.icon, autopCount, matchesSearch: !query || @@ -708,6 +766,7 @@ const sidebarNodes = computed(() => { } }) .filter((node) => node.matchesSearch && node.matchesRole && node.matchesPhase && node.matchesAuto) + .map(({ matchesSearch, matchesRole, matchesPhase, matchesAuto, ...rest }) => rest) }) const canvasNodes = computed(() => { @@ -978,6 +1037,10 @@ async function runImport() { } } +function toggleAutopOnly() { + nodeFilter.autopOnly = !nodeFilter.autopOnly +} + function selectNode(stateId: string) { selectedNodeId.value = stateId inspectorTab.value = 'general' @@ -1093,52 +1156,128 @@ async function deleteNode() { function autoLayoutNodes() { if (!flowDetail.value) return - const phaseColumns = new Map() - const phases = flowForm.phases.length ? flowForm.phases : ['General'] - phases.forEach((phase, index) => phaseColumns.set(phase, index)) - const phaseRows = new Map() - for (const node of flowDetail.value.nodes) { - const phase = phaseColumns.has(node.phase) ? node.phase : phases[0] - if (!phaseColumns.has(node.phase)) { - phaseColumns.set(node.phase, phaseColumns.size) + const nodes = flowDetail.value.nodes + if (!nodes.length) return + + const nodeMap = new Map(nodes.map((node) => [node.stateId, node])) + const adjacency = new Map() + for (const node of nodes) { + const targets = (node.transitions || []) + .map((transition) => transition.target) + .filter((target) => nodeMap.has(target)) + adjacency.set(node.stateId, Array.from(new Set(targets))) + } + + const startStateCandidate = flowDetail.value.flow.startState + const startState = startStateCandidate && nodeMap.has(startStateCandidate) + ? startStateCandidate + : nodes[0]?.stateId + + const queue: string[] = [] + const depths = new Map() + const seen = new Set() + + if (startState) { + queue.push(startState) + depths.set(startState, 0) + seen.add(startState) + } + + while (queue.length > 0) { + const current = queue.shift()! + const depth = depths.get(current) ?? 0 + const neighbours = adjacency.get(current) ?? [] + for (const target of neighbours) { + const nextDepth = depth + 1 + const existing = depths.get(target) + if (existing === undefined || nextDepth < existing) { + depths.set(target, nextDepth) + } + if (!seen.has(target)) { + queue.push(target) + seen.add(target) + } } - const column = phaseColumns.get(node.phase) ?? 0 - const row = phaseRows.get(node.phase) ?? 0 - node.layout = { - x: column * 360, - y: row * 220, - color: node.layout?.color, + } + + let maxDepth = depths.size ? Math.max(...Array.from(depths.values())) : -1 + const remaining = nodes + .map((node) => node.stateId) + .filter((stateId) => !depths.has(stateId)) + .sort((a, b) => a.localeCompare(b)) + + for (const stateId of remaining) { + maxDepth += 1 + depths.set(stateId, maxDepth) + } + + const levels = new Map() + for (const node of nodes) { + const depth = depths.get(node.stateId) ?? 0 + if (!levels.has(depth)) { + levels.set(depth, []) } - phaseRows.set(node.phase, row + 1) - if (nodeForm.value && nodeForm.value.stateId === node.stateId) { - nodeForm.value.layout = cloneNode(node.layout) + levels.get(depth)!.push(node) + } + + const sortedDepths = Array.from(levels.keys()).sort((a, b) => a - b) + let maxLevelWidth = 1 + for (const depth of sortedDepths) { + const group = levels.get(depth)! + group.sort((a, b) => a.stateId.localeCompare(b.stateId)) + if (group.length > maxLevelWidth) { + maxLevelWidth = group.length } - void api.request(`/api/editor/flows/${flowDetail.value.flow.slug}/nodes/${node.stateId}/layout`, { - method: 'PATCH', - body: node.layout, + } + + const BASE_X = 160 + const BASE_Y = 80 + const COLUMN_SPACING = 340 + const ROW_SPACING = 240 + + for (const depth of sortedDepths) { + const group = levels.get(depth)! + const offsetX = BASE_X + ((maxLevelWidth - group.length) * COLUMN_SPACING) / 2 + group.forEach((node, index) => { + const x = Math.round(offsetX + index * COLUMN_SPACING) + const y = Math.round(BASE_Y + depth * ROW_SPACING) + const layout: DecisionNodeLayout = { ...(node.layout || {}), x, y } + node.layout = layout + if (nodeForm.value?.stateId === node.stateId) { + nodeForm.value.layout = cloneNode(layout) + } + void api.request(`/api/editor/flows/${flowDetail.value.flow.slug}/nodes/${node.stateId}/layout`, { + method: 'PATCH', + body: layout, + }) }) } + showSnack('Auto-Layout angewendet.') } function onNodeMove(payload: { stateId: string; x: number; y: number }) { if (!flowDetail.value) return + const x = Math.max(0, Math.round(payload.x)) + const y = Math.max(0, Math.round(payload.y)) const target = flowDetail.value.nodes.find((node) => node.stateId === payload.stateId) if (target) { - target.layout = { ...(target.layout || { x: 0, y: 0 }), x: Math.round(payload.x), y: Math.round(payload.y) } + target.layout = { ...(target.layout || { x: 0, y: 0 }), x, y } } if (nodeForm.value?.stateId === payload.stateId) { - nodeForm.value.layout = { ...(nodeForm.value.layout || { x: 0, y: 0 }), x: Math.round(payload.x), y: Math.round(payload.y) } + nodeForm.value.layout = { ...(nodeForm.value.layout || { x: 0, y: 0 }), x, y } } } async function onNodeDrop(payload: { stateId: string; x: number; y: number }) { if (!flowDetail.value) return - onNodeMove(payload) + const x = Math.max(0, Math.round(payload.x)) + const y = Math.max(0, Math.round(payload.y)) + onNodeMove({ ...payload, x, y }) try { await api.request(`/api/editor/flows/${flowDetail.value.flow.slug}/nodes/${payload.stateId}/layout`, { method: 'PATCH', - body: { x: Math.round(payload.x), y: Math.round(payload.y) }, + body: { x, y }, }) } catch (error) { console.error('Failed to persist node layout', error)