Files
nexus/frontend/src/components/dashboard/v2/FlowCanvas.vue
T
developer 2d6e3537e8
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
feat(v2): FlowCanvas, AgentNode, AlertBar, useFlowLayout composable
2026-06-12 00:24:56 +02:00

486 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* FlowCanvas — SVG-Kanten + Auto-Layout + AgentNode-Karten
*
* Props:
* agents Liste der AgentNodeData
* positions Record<id, {x,y}> mit aktuellen Positionen
*
* Emits:
* select Agent ausgewählt (id)
* add Neuen Agent hinzufügen
* updatePositions Positionsänderung
*/
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue'
const props = defineProps<{
agents: AgentNodeData[]
positions: Record<string, { x: number; y: number }>
enteringIds: string[]
}>()
const emit = defineEmits<{
select: [id: string]
add: []
resetLayout: []
updatePositions: [positions: Record<string, { x: number; y: number }>]
}>()
/* ── Refs ───────────────────────────────────────── */
const flowRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGSVGElement | null>(null)
const edgesDefs = ref('')
const edgesPaths = ref('')
const edgesPulses = ref('')
/* ── Computed ───────────────────────────────────── */
const agentCount = computed(() => props.agents.length)
const autoPositions = computed(() => autoLayout(props.agents))
// Layout label
const layoutLabel = computed(() => {
const n = props.agents.length - 1
if (n <= 0) return `${props.agents.length} Agents`
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const rows = Math.ceil(n / maxPerRow)
const hasCustom = Object.keys(props.positions).length > 0
if (hasCustom) {
return `✦ Eigenes Layout · ${props.agents.length} Agents gesamt`
}
return `Layout: ${rows} ${rows === 1 ? 'Reihe' : 'Reihen'} × ${maxPerRow} · ${props.agents.length} Agents gesamt`
})
/* ── Edge Rendering ────────────────────────────── */
function isActive(status: string) {
return status === 'work' || status === 'think'
}
function renderEdges() {
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const svg = svgRef.value
if (!svg) return
svg.setAttribute('width', String(fr.width))
svg.setAttribute('height', String(fr.height))
svg.setAttribute('viewBox', `0 0 ${fr.width} ${fr.height}`)
// Node centers in pixel coordinates
function center(id: string): { x: number; y: number } | null {
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
if (!el) return null
const nr = el.getBoundingClientRect()
return {
x: nr.left - fr.left + nr.width / 2,
y: nr.top - fr.top + nr.height / 2,
}
}
const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
let paths = ''
let pulses = ''
let idCounter = 0
edgeList.forEach((e) => {
const c1 = center(e.a)
const c2 = center(e.b)
if (!c1 || !c2) return
const d = curve(c1, c2)
const a1Status = props.agents.find(a => a.id === e.a)?.status || 'idle'
const a2Status = props.agents.find(a => a.id === e.b)?.status || 'idle'
const live = isActive(a1Status) && isActive(a2Status)
const pathId = `ep${idCounter++}`
if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') {
// Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else {
// Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
}
})
edgesDefs.value = defs
edgesPaths.value = paths
edgesPulses.value = pulses
}
/* ── Resize Observer ──────────────────────────── */
let resizeObserver: ResizeObserver | null = null
function setupObserver() {
if (!flowRef.value) return
resizeObserver = new ResizeObserver(() => {
// Debounce via requestAnimationFrame
if (!debounceRaf) debounceRaf = requestAnimationFrame(() => {
debounceRaf = null
renderEdges()
})
})
resizeObserver.observe(flowRef.value)
}
let debounceRaf: number | null = null
function teardownObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (debounceRaf) {
cancelAnimationFrame(debounceRaf)
debounceRaf = null
}
}
onMounted(() => {
setupObserver()
// Initial render after DOM settles
requestAnimationFrame(() => renderEdges())
})
onUnmounted(() => {
teardownObserver()
})
// Re-render edges when agents or positions change
watch(
() => [props.agents.length, props.positions],
() => {
// Wait for DOM update (AgentNode transitions)
setTimeout(() => renderEdges(), 200)
},
{ deep: true }
)
/* ── Drag & Drop ──────────────────────────────── */
const DRAG_THRESHOLD = 5
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
let drag: DragState | null = null
function onPointerDown(e: PointerEvent) {
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag = {
id: node.dataset.id || '',
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - (nr.left + nr.width / 2),
oy: e.clientY - (nr.top + nr.height / 2),
moved: false,
raf: null,
}
node.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!drag) return
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
if (!drag.moved && dist < DRAG_THRESHOLD) return
if (!drag.moved) {
drag.moved = true
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
// Direct DOM manipulation for responsiveness
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
// Update positions state
const newPos = { ...props.positions }
newPos[drag.id] = { x, y }
emit('updatePositions', newPos)
// Debounced edge re-render
if (!drag.raf) {
drag.raf = requestAnimationFrame(() => {
renderEdges()
if (drag) drag.raf = null
})
}
}
function onPointerUp() {
if (!drag) return
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (!drag.moved) {
// Was a click — emit select
emit('select', drag.id)
}
drag = null
}
/* ── Keyboard handler for Enter key on buttons ── */
function handleReset() {
emit('resetLayout')
nextTick(() => renderEdges())
}
</script>
<template>
<div
ref="flowRef"
class="flow"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<!-- Header -->
<div class="flow-h">
<span class="header-icon" v-html="icons.flow || ''"></span>
<h3>Live-Orchestrierung</h3>
<span class="flow-count">{{ agentCount }} Agents</span>
<button
class="reset-btn"
title="Auto-Layout wiederherstellen"
@click="handleReset"
>
<span class="btn-icon" v-html="icons.flow || ''"></span>
Reset
</button>
<button class="add-btn" @click="$emit('add')">
<span class="btn-icon" v-html="icons.plus || ''"></span>
Agent hinzufügen
</button>
</div>
<!-- SVG Layer -->
<svg
ref="svgRef"
class="edges"
v-html="edgesDefs + edgesPaths + edgesPulses"
></svg>
<!-- Agent Nodes -->
<div class="nodes-layer" v-html="''"></div>
<AgentNode
v-for="agent in agents"
:key="agent.id"
:agent="agent"
:left="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).x"
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
:entering="enteringIds.includes(agent.id)"
:data-id="agent.id"
@select="(id: string) => $emit('select', id)"
/>
<!-- Layout Label -->
<div class="layout-label">{{ layoutLabel }}</div>
</div>
</template>
<style scoped>
.flow {
flex: 1;
position: relative;
border-radius: var(--r);
overflow: hidden;
min-height: 0;
border: 1px solid var(--line);
background:
radial-gradient(120% 90% at 50% 0%, rgba(124, 108, 255, 0.10), transparent 60%);
}
.flow-h {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 4;
display: flex;
align-items: center;
gap: 9px;
padding: 13px 16px;
}
.flow-h h3 {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
}
.header-icon :deep(svg) {
width: 18px;
height: 18px;
color: var(--a-mid);
}
.flow-count {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
background: rgba(124, 108, 255, 0.14);
color: var(--tx-2);
font-variant-numeric: tabular-nums;
}
.reset-btn {
height: 30px;
padding: 0 11px;
border-radius: 9px;
background: rgba(124, 108, 255, 0.1);
border: 1px solid var(--line-2);
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.15s;
}
.reset-btn:hover {
background: rgba(124, 108, 255, 0.18);
color: var(--tx);
}
.reset-btn .btn-icon :deep(svg) {
width: 13px;
height: 13px;
}
.add-btn {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 14px;
border-radius: 10px;
background: var(--grad);
border: none;
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--glow-purple);
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-btn .btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
/* ── SVG Layer ────────────────────────────────── */
.edges {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── Layout Label ─────────────────────────────── */
.layout-label {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
color: var(--tx-3);
background: rgba(10, 8, 24, 0.7);
padding: 5px 14px;
border-radius: 20px;
border: 1px solid var(--line);
backdrop-filter: blur(8px);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* ── Drag state ───────────────────────────────── */
:deep(.node.dragging) {
cursor: grabbing;
transition: none !important;
z-index: 10;
}
:deep(.node.dragging .ncard) {
box-shadow: 0 0 0 2px var(--a-mid), 0 0 36px -2px rgba(124, 108, 255, 0.9) !important;
transform: scale(1.04);
}
/* Dash animation */
@keyframes dashmove {
to {
stroke-dashoffset: -28;
}
}
/* Node cursor */
:deep(.node) {
cursor: grab;
}
:deep(.node.dragging) {
cursor: grabbing;
}
</style>