b535fd1ab3
- SVG-Layer hinter Cards mit Bézier-Kurven (C-Pfad) - 4 Linien starten parallel an Iris' unterer Mitte, keine Überschneidungen - Links-Spalte: Linien kurven nach links, treffen rechte Card-Kante - Rechts-Spalte: Linien kurven nach rechts, treffen linke Card-Kante - JS-Pulse-Animation via requestAnimationFrame (~3s/Cycle) - Aktive Agenten: glow box-shadow, hellere Linien, full-opacity Puls - Responsive ResizeObserver, mobile Single-Column - Titel 'AI Team Network', Legende (active/idle/pulse)
418 lines
11 KiB
Vue
418 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
|
import AgentCard from './AgentCard.vue'
|
|
|
|
interface AgentData {
|
|
id: string
|
|
name: string
|
|
role: string
|
|
description: string
|
|
tags: string[]
|
|
color: string
|
|
icon: string
|
|
hero?: boolean
|
|
}
|
|
|
|
const props = defineProps<{
|
|
agents: AgentData[]
|
|
heroId?: string
|
|
activeAgents?: string[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [id: string]
|
|
}>()
|
|
|
|
// ── Layout refs ──
|
|
const networkRef = ref<HTMLDivElement | null>(null)
|
|
|
|
interface CardBox {
|
|
left: number
|
|
right: number
|
|
top: number
|
|
bottom: number
|
|
cx: number
|
|
cy: number
|
|
width: number
|
|
height: number
|
|
}
|
|
const cardPositions = ref<Record<string, CardBox>>({})
|
|
const svgWidth = ref(0)
|
|
const svgHeight = ref(0)
|
|
|
|
// ── Computed data ──
|
|
const hero = computed(() => props.agents.find(a => a.id === props.heroId) ?? props.agents[0])
|
|
const childAgents = computed(() => props.agents.filter(a => a.id !== props.heroId))
|
|
|
|
function isActive(id: string): boolean {
|
|
return props.activeAgents?.includes(id) ?? false
|
|
}
|
|
|
|
// ── Position measurement ──
|
|
function updatePositions() {
|
|
if (!networkRef.value) return
|
|
const rect = networkRef.value.getBoundingClientRect()
|
|
svgWidth.value = rect.width
|
|
svgHeight.value = rect.height
|
|
|
|
const cards = networkRef.value.querySelectorAll('[data-agent-id]')
|
|
const positions: Record<string, CardBox> = {}
|
|
cards.forEach(el => {
|
|
const id = el.getAttribute('data-agent-id')
|
|
if (!id) return
|
|
const r = el.getBoundingClientRect()
|
|
positions[id] = {
|
|
left: r.left - rect.left,
|
|
right: r.left + r.width - rect.left,
|
|
top: r.top - rect.top,
|
|
bottom: r.top + r.height - rect.top,
|
|
cx: r.left + r.width / 2 - rect.left,
|
|
cy: r.top + r.height / 2 - rect.top,
|
|
width: r.width,
|
|
height: r.height,
|
|
}
|
|
})
|
|
cardPositions.value = positions
|
|
}
|
|
|
|
// ── SVG path computation ──
|
|
interface ConnectionPath {
|
|
d: string
|
|
length: number
|
|
}
|
|
|
|
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
|
|
const result: Record<string, ConnectionPath | null> = {}
|
|
const pos = cardPositions.value
|
|
const heroEntry = props.agents.find(a => a.id === props.heroId)
|
|
const heroId = heroEntry?.id ?? ''
|
|
const iris = heroId ? pos[heroId] : undefined
|
|
if (!iris) return result
|
|
|
|
const children = childAgents.value
|
|
const total = children.length
|
|
if (total === 0) return result
|
|
|
|
for (let idx = 0; idx < total; idx++) {
|
|
const agent = children[idx]
|
|
const agentPos = pos[agent.id]
|
|
if (!agentPos) {
|
|
result[agent.id] = null
|
|
continue
|
|
}
|
|
|
|
// Spread start points across Iris bottom edge (30%-70% range)
|
|
const t = total > 1 ? idx / (total - 1) : 0.5
|
|
const startX = iris.left + iris.width * (0.30 + t * 0.40)
|
|
const startY = iris.bottom - 1
|
|
|
|
// Determine column: left or right of Iris center
|
|
const isLeftColumn = agentPos.cx < iris.cx
|
|
|
|
// End point: approach from side, 8px before card edge
|
|
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
|
|
const endY = agentPos.cy
|
|
|
|
// Bézier control points
|
|
const cp1x = startX
|
|
const cp1y = startY + 40
|
|
const cp2x = endX + (isLeftColumn ? 50 : -50)
|
|
const cp2y = endY - 10
|
|
|
|
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
|
|
result[agent.id] = { d, length: 0 }
|
|
}
|
|
return result
|
|
})
|
|
|
|
// ── Pulse animation (JS-driven via requestAnimationFrame) ──
|
|
let animFrameId: number | null = null
|
|
let lastAnimTime = 0
|
|
|
|
// Track path elements and animation offset per agent
|
|
const pathElements = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseOffsets = ref<Record<string, number>>({})
|
|
|
|
function storePathRef(id: string) {
|
|
return (el: SVGPathElement | null) => {
|
|
pathElements.value[id] = el
|
|
}
|
|
}
|
|
|
|
function storePulseRef(id: string) {
|
|
return (el: SVGPathElement | null) => {
|
|
pulseElements.value[id] = el
|
|
}
|
|
}
|
|
|
|
/** Refresh path lengths and pulse dasharrays from current SVG elements */
|
|
function refreshPathLengths() {
|
|
for (const id of childAgents.value.map(a => a.id)) {
|
|
const pathEl = pathElements.value[id]
|
|
const pulseEl = pulseElements.value[id]
|
|
const p = connectionPaths.value[id]
|
|
if (pathEl && p) {
|
|
p.length = pathEl.getTotalLength()
|
|
}
|
|
if (pulseEl && p && p.length > 0) {
|
|
if (pulseOffsets.value[id] === undefined) {
|
|
pulseOffsets.value[id] = 0
|
|
}
|
|
pulseEl.setAttribute('stroke-dasharray', `10 ${p.length}`)
|
|
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
}
|
|
}
|
|
}
|
|
|
|
function startPulseAnimation() {
|
|
const speeds: Record<string, number> = {}
|
|
|
|
refreshPathLengths()
|
|
|
|
for (const id of childAgents.value.map(a => a.id)) {
|
|
const p = connectionPaths.value[id]
|
|
if (p && p.length > 0) {
|
|
speeds[id] = p.length / 3000 // full traversal in ~3s
|
|
if (pulseOffsets.value[id] === undefined) {
|
|
pulseOffsets.value[id] = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
lastAnimTime = performance.now()
|
|
|
|
function tick(now: number) {
|
|
const dt = now - lastAnimTime
|
|
lastAnimTime = now
|
|
|
|
const children = childAgents.value
|
|
for (let i = 0; i < children.length; i++) {
|
|
const id = children[i].id
|
|
const pathEl = pathElements.value[id]
|
|
const pulseEl = pulseElements.value[id]
|
|
const p = connectionPaths.value[id]
|
|
if (!pathEl || !pulseEl || !p) continue
|
|
|
|
const len = p.length
|
|
if (len <= 0) continue
|
|
|
|
const currentOffset = pulseOffsets.value[id] ?? 0
|
|
const newOffset = currentOffset + (speeds[id] ?? len / 3000) * dt
|
|
const cycleLen = len + 10
|
|
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
|
|
|
|
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
}
|
|
|
|
animFrameId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
animFrameId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
function stopPulseAnimation() {
|
|
if (animFrameId !== null) {
|
|
cancelAnimationFrame(animFrameId)
|
|
animFrameId = null
|
|
}
|
|
}
|
|
|
|
// ── Lifecycle ──
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
updatePositions()
|
|
|
|
// Wait for SVG to render so path refs are populated
|
|
await nextTick()
|
|
updatePositions()
|
|
refreshPathLengths()
|
|
|
|
startPulseAnimation()
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
updatePositions()
|
|
// Paths changed — recalculate lengths and dasharrays
|
|
requestAnimationFrame(() => {
|
|
refreshPathLengths()
|
|
})
|
|
})
|
|
if (networkRef.value) {
|
|
resizeObserver.observe(networkRef.value)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopPulseAnimation()
|
|
resizeObserver?.disconnect()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="networkRef" class="team-network">
|
|
<!-- SVG Connection Layer -->
|
|
<svg
|
|
v-if="svgWidth > 0 && svgHeight > 0"
|
|
class="network-svg"
|
|
:width="svgWidth"
|
|
:height="svgHeight"
|
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<defs>
|
|
<filter
|
|
v-for="agent in childAgents"
|
|
:key="`glow-${agent.id}`"
|
|
:id="`glow-${agent.id}`"
|
|
x="-30%" y="-30%" width="160%" height="160%"
|
|
>
|
|
<feGaussianBlur stdDeviation="4" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- Connection lines for each agent -->
|
|
<template v-for="agent in childAgents" :key="agent.id">
|
|
<!-- Base line -->
|
|
<path
|
|
v-if="connectionPaths[agent.id]"
|
|
:ref="storePathRef(agent.id)"
|
|
:d="connectionPaths[agent.id]!.d"
|
|
:stroke="agent.color"
|
|
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
|
|
fill="none"
|
|
:opacity="isActive(agent.id) ? 0.7 : 0.25"
|
|
stroke-linecap="round"
|
|
/>
|
|
|
|
<!-- Glow line for active agent -->
|
|
<path
|
|
v-if="isActive(agent.id) && connectionPaths[agent.id]"
|
|
:d="connectionPaths[agent.id]!.d"
|
|
:stroke="agent.color"
|
|
stroke-width="4"
|
|
fill="none"
|
|
stroke-linecap="round"
|
|
:filter="`url(#glow-${agent.id})`"
|
|
opacity="0.5"
|
|
/>
|
|
|
|
<!-- Pulse line (white dashed segment moving along) -->
|
|
<path
|
|
v-if="connectionPaths[agent.id]"
|
|
:ref="storePulseRef(agent.id)"
|
|
:d="connectionPaths[agent.id]!.d"
|
|
stroke="white"
|
|
stroke-width="3"
|
|
fill="none"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
:opacity="isActive(agent.id) ? 1 : 0.4"
|
|
/>
|
|
</template>
|
|
</svg>
|
|
|
|
<!-- Cards Layer (above SVG) -->
|
|
<div class="cards-layer">
|
|
<!-- Hero: Iris centered top -->
|
|
<div class="hero-slot" data-agent-id="iris">
|
|
<AgentCard
|
|
v-bind="hero"
|
|
:class="{ 'hero-active': isActive(hero.id) }"
|
|
:style="{
|
|
boxShadow: isActive(hero.id)
|
|
? `0 0 20px ${hero.color}44`
|
|
: undefined,
|
|
borderColor: isActive(hero.id) ? hero.color : undefined,
|
|
}"
|
|
@click="emit('select', hero.id)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Agent Grid: 2 columns x 2 rows -->
|
|
<div class="agent-grid">
|
|
<div
|
|
v-for="agent in childAgents"
|
|
:key="agent.id"
|
|
:data-agent-id="agent.id"
|
|
class="agent-slot"
|
|
>
|
|
<AgentCard
|
|
v-bind="agent"
|
|
:style="{
|
|
boxShadow: isActive(agent.id)
|
|
? `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`
|
|
: undefined,
|
|
borderColor: isActive(agent.id) ? agent.color : undefined,
|
|
}"
|
|
@click="emit('select', agent.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.team-network {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.network-svg {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
overflow: visible;
|
|
}
|
|
|
|
.cards-layer {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 32px;
|
|
}
|
|
|
|
.hero-slot {
|
|
width: 100%;
|
|
max-width: 520px;
|
|
transition: border-color 0.3s, box-shadow 0.3s;
|
|
}
|
|
|
|
.hero-active {
|
|
transition: border-color 0.3s, box-shadow 0.3s;
|
|
}
|
|
|
|
.agent-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
width: 100%;
|
|
max-width: 820px;
|
|
}
|
|
|
|
.agent-slot {
|
|
width: 100%;
|
|
transition: border-color 0.3s, box-shadow 0.3s;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.agent-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.cards-layer {
|
|
gap: 20px;
|
|
}
|
|
}
|
|
</style>
|