485 lines
13 KiB
Vue
485 lines
13 KiB
Vue
<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 -->
|
||
<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>
|