diff --git a/app/components/editor/DecisionNodeCanvas.vue b/app/components/editor/DecisionNodeCanvas.vue index 9183a4b..7b9e38b 100644 --- a/app/components/editor/DecisionNodeCanvas.vue +++ b/app/components/editor/DecisionNodeCanvas.vue @@ -20,8 +20,8 @@ :stroke="edge.color" :stroke-width="edge.highlighted ? 3 : 1.75" :stroke-dasharray="edge.dashed ? '6 6' : undefined" - stroke-linecap="'round'" - stroke-linejoin="'round'" + stroke-linecap="round" + stroke-linejoin="round" class="transition-all duration-150" opacity="0.9" /> @@ -41,40 +41,46 @@ @pointerdown.stop="(event) => onNodePointerDown(event, node)" @dblclick.prevent="() => emit('select', node.id)" > -
-
- - {{ node.role }} - - {{ node.phase }} + + +
+
+
+ + {{ node.role }} + + {{ node.phase }} +
+
+ Start + End +
-
- Start - End -
-
-
-

{{ node.id }}

-

{{ node.title || 'Untitled node' }}

-

{{ node.summary }}

-
- - → {{ transition.target }} - {{ transition.type }} - -
-
- - {{ node.autopCount }} auto trigger{{ node.autopCount > 1 ? 's' : '' }} +
+
+

{{ node.id }}

+

{{ node.title || 'Untitled node' }}

+

{{ node.summary }}

+
+
+ + → {{ transition.target }} + {{ transition.type }} + +
+
+ + {{ node.autopCount }} auto trigger{{ node.autopCount > 1 ? 's' : '' }} +
@@ -232,8 +238,16 @@ interface EdgeDefinition { highlighted: boolean } +interface NodePosition { + x: number + y: number + width: number + height: number + highlighted?: boolean +} + const edges = computed(() => { - const positions = new Map( + const positions = new Map( preparedNodes.value.map((node) => [ node.id, { @@ -255,7 +269,9 @@ const edges = computed(() => { for (const transition of node.model.transitions || []) { const target = positions.get(transition.target) if (!target) continue - const path = computeEdgePath(source, target) + const path = computeEdgePath(source, target, { + selfLoop: transition.target === node.id, + }) const color = transitionColor(transition) const dashed = Boolean(transition.autoTrigger || transition.type === 'auto') const highlighted = node.selected || source.highlighted @@ -324,17 +340,129 @@ function transitionColor(transition: DecisionNodeTransition) { } } -function computeEdgePath(source: { x: number; y: number; width: number; height: number }, target: { x: number; y: number; width: number; height: number }) { - const startX = source.x + source.width - const startY = source.y + source.height / 2 - const endX = target.x - const endY = target.y + target.height / 2 - const deltaX = Math.max(80, Math.abs(endX - startX) / 1.5) - const control1X = startX + deltaX - const control2X = endX - deltaX - return `M ${startX} ${startY} C ${control1X} ${startY}, ${control2X} ${endY}, ${endX} ${endY}` +type PathPoint = { x: number; y: number } + +function computeEdgePath( + source: NodePosition, + target: NodePosition, + options: { selfLoop?: boolean } = {} +) { + if (options.selfLoop) { + return computeSelfLoopPath(source) + } + + const startX = source.x + source.width / 2 + const startY = source.y + source.height + const endX = target.x + target.width / 2 + const endY = target.y + + if (endY >= startY) { + const verticalDistance = endY - startY + const deltaY = Math.max(80, verticalDistance / 1.5) + const control1Y = startY + deltaY + const control2Y = endY - Math.min(deltaY, verticalDistance / 2) + return `M ${startX} ${startY} C ${startX} ${control1Y}, ${endX} ${control2Y}, ${endX} ${endY}` + } + + const exitOffset = Math.max(24, Math.min(72, source.height * 0.25)) + const horizontalGap = Math.abs(endX - startX) + const horizontalExtra = Math.max(180, horizontalGap * 0.6) + const outerRight = Math.max(source.x + source.width, target.x + target.width) + const outerLeft = Math.min(source.x, target.x) + const direction = endX >= startX ? 1 : -1 + const sideX = direction === 1 ? outerRight + horizontalExtra : outerLeft - horizontalExtra + const dropY = startY + exitOffset + const topCandidate = Math.min(source.y, target.y) - 100 + const topLimit = Math.min(startY - 60, endY - 60) + let loopTop = Math.min(topCandidate, topLimit) + + if (!Number.isFinite(loopTop)) { + loopTop = topLimit + } + if (!Number.isFinite(loopTop)) { + loopTop = startY - 80 + } + if (loopTop >= dropY - 20) { + loopTop = dropY - 60 + } + + const points: PathPoint[] = [ + { x: startX, y: startY }, + { x: startX, y: dropY }, + { x: sideX, y: dropY }, + { x: sideX, y: loopTop }, + { x: endX, y: loopTop }, + { x: endX, y: endY }, + ] + + return buildSmoothPath(points, 48) } +function computeSelfLoopPath(node: NodePosition) { + const startX = node.x + node.width / 2 + const startY = node.y + node.height + const exitOffset = Math.max(28, Math.min(72, node.height * 0.25)) + const sideOffset = Math.max(200, node.width * 0.75) + const sideX = node.x + node.width + sideOffset + const loopTop = node.y - 120 + + const points: PathPoint[] = [ + { x: startX, y: startY }, + { x: startX, y: startY + exitOffset }, + { x: sideX, y: startY + exitOffset }, + { x: sideX, y: loopTop }, + { x: startX, y: loopTop }, + { x: startX, y: node.y }, + ] + + return buildSmoothPath(points, 44) +} + +function buildSmoothPath(points: PathPoint[], radius = 32) { + if (points.length < 2) { + return '' + } + + const commands: string[] = [`M ${points[0].x} ${points[0].y}`] + + for (let i = 1; i < points.length; i += 1) { + const prev = points[i - 1] + const curr = points[i] + const next = points[i + 1] + + if (!next) { + commands.push(`L ${curr.x} ${curr.y}`) + continue + } + + const prevVectorX = curr.x - prev.x + const prevVectorY = curr.y - prev.y + const nextVectorX = next.x - curr.x + const nextVectorY = next.y - curr.y + const prevLength = Math.hypot(prevVectorX, prevVectorY) + const nextLength = Math.hypot(nextVectorX, nextVectorY) + + if (prevLength === 0 || nextLength === 0) { + commands.push(`L ${curr.x} ${curr.y}`) + continue + } + + const startOffset = Math.min(radius, prevLength / 2) + const endOffset = Math.min(radius, nextLength / 2) + + const startX = curr.x - (prevVectorX / prevLength) * startOffset + const startY = curr.y - (prevVectorY / prevLength) * startOffset + const endX = curr.x + (nextVectorX / nextLength) * endOffset + const endY = curr.y + (nextVectorY / nextLength) * endOffset + + commands.push(`L ${startX} ${startY}`) + commands.push(`Q ${curr.x} ${curr.y} ${endX} ${endY}`) + } + + return commands.join(' ') +} + + let panPointerId: number | null = null let panStart: CanvasPan = { x: 0, y: 0 } let pointerOrigin = { x: 0, y: 0 } @@ -492,4 +620,20 @@ onBeforeUnmount(() => { .control-btn { @apply pointer-events-auto flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/40 text-white/80 transition hover:border-cyan-400 hover:text-cyan-200; } + +.node-connector { + @apply pointer-events-none absolute h-3 w-3 rounded-full border-2 border-white/60 bg-[#070d1a]; +} + +.node-connector--input { + top: 0; + left: 50%; + transform: translate(-50%, -50%); +} + +.node-connector--output { + bottom: 0; + left: 50%; + transform: translate(-50%, 50%); +} diff --git a/app/pages/editor/index.vue b/app/pages/editor/index.vue index 8df6fee..86dd362 100644 --- a/app/pages/editor/index.vue +++ b/app/pages/editor/index.vue @@ -6,125 +6,181 @@ -
-
- -
-

OpenSquawk

-

Decision Flow Studio

+
+
+
+
+ +
+
+

OpenSquawk

+

Decision Flow Studio

+
+
+ - - - - - - - - - - - - - - - - - -
-
-

Aktueller Flow

-

+

+ + + + + + Node-Filter + + {{ activeFilterCount }} + + + + + + + + + + + Zurücksetzen + Fertig + + + +
+
+ - + {{ flowDetail.flow.slug }} Auto-Layout
- +
+
+
+
+ Nodes + + {{ nodeSelectorItems.length }} + +
+
+ + + + + + + +

Kein Flow ausgewählt.

+
+
+
+ + {{ badge.label }} + +
+
+
+
+ + + {{ flowsError }} + +
-
-
- -
-
+ + Allgemein + Transitions + LLM Template + Metadata +
@@ -642,14 +778,32 @@ const nodeFilter = reactive({ autopOnly: false, }) +const filtersMenuOpen = ref(false) + +const activeFilterCount = computed(() => { + let count = 0 + if (nodeFilter.role !== 'all') count += 1 + if (nodeFilter.phase !== 'all') count += 1 + if (nodeFilter.autopOnly) count += 1 + return count +}) + +const activeFilterBadges = computed(() => { + const badges: { label: string; color: string }[] = [] + if (nodeFilter.role !== 'all') { + badges.push({ label: `Rolle: ${nodeFilter.role}`, color: 'cyan' }) + } + if (nodeFilter.phase !== 'all') { + badges.push({ label: `Phase: ${nodeFilter.phase}`, color: 'purple' }) + } + if (nodeFilter.autopOnly) { + badges.push({ label: 'Auto-Trigger', color: 'amber' }) + } + return badges +}) + const selectedNodeId = ref(null) const inspectorTab = ref<'general' | 'transitions' | 'llm' | 'metadata'>('general') -const inspectorSections = [ - { value: 'general', label: 'Allgemein', icon: 'mdi-tune-variant' }, - { value: 'transitions', label: 'Transitions', icon: 'mdi-source-branch' }, - { value: 'llm', label: 'LLM Template', icon: 'mdi-robot-outline' }, - { value: 'metadata', label: 'Metadata', icon: 'mdi-text-box-search-outline' }, -] as const const nodeForm = ref(null) const nodeSnapshot = ref(null) const nodeSaving = ref(false) @@ -683,6 +837,39 @@ const phaseFilterOptions = computed(() => { return Array.from(phases) }) +const nodeSelectorItems = computed(() => { + if (!flowDetail.value) return [] + const query = nodeFilter.search.trim().toLowerCase() + const role = nodeFilter.role + const phase = nodeFilter.phase + const autopOnly = nodeFilter.autopOnly + return flowDetail.value.nodes + .slice() + .sort((a, b) => a.stateId.localeCompare(b.stateId)) + .map((node) => { + const autopCount = (node.transitions || []).filter((t) => t.autoTrigger).length + return { + id: node.stateId, + title: node.title, + summary: node.summary, + phase: node.phase, + role: node.role, + icon: node.layout?.icon, + autopCount, + matchesSearch: + !query || + [node.stateId, node.title, node.summary] + .filter(Boolean) + .some((entry) => entry!.toLowerCase().includes(query)), + matchesRole: role === 'all' || node.role === role, + matchesPhase: phase === 'all' || node.phase === phase, + matchesAuto: !autopOnly || autopCount > 0, + } + }) + .filter((node) => node.matchesSearch && node.matchesRole && node.matchesPhase && node.matchesAuto) + .map(({ matchesSearch, matchesRole, matchesPhase, matchesAuto, ...rest }) => rest) +}) + const canvasNodes = computed(() => { if (!flowDetail.value) return [] const flow = flowDetail.value.flow @@ -951,8 +1138,10 @@ async function runImport() { } } -function toggleAutopOnly() { - nodeFilter.autopOnly = !nodeFilter.autopOnly +function resetNodeFilters() { + nodeFilter.role = 'all' + nodeFilter.phase = 'all' + nodeFilter.autopOnly = false } function selectNode(stateId: string) {