b8498f47bb
- AgentNodeData: Remove redundant fields task/runtime (dup of currentTask/runtimeSeconds) - useTeamNetworkSvg: Extract SVG layout, path computation + pulse animation from TeamNetwork - TeamNetwork: Use AgentNodeData type, fix undefined pulseElements2/storePulseRef2, remove unused props - Rename MissionCard.vue → TaskCard.vue (matches actual usage) - Extract FeedDetailModal from OperationsFeed (eliminates :global() CSS conflict with AgentModal) - DashboardView: Fix type import path (../../ → ../), remove dead TeamNetwork props - AgentModal: Remove unused thinkingStreamRef template ref Build: vue-tsc --noEmit 0 errors, vite build ✓
267 lines
7.5 KiB
TypeScript
267 lines
7.5 KiB
TypeScript
import { ref, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
|
|
import type { AgentNodeData } from './useDashboardData'
|
|
|
|
export interface CardBox {
|
|
left: number
|
|
right: number
|
|
top: number
|
|
bottom: number
|
|
cx: number
|
|
cy: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
export interface ConnectionPath {
|
|
d: string
|
|
length: number
|
|
}
|
|
|
|
export function useTeamNetworkSvg(
|
|
networkRef: Ref<HTMLElement | null>,
|
|
agents: Ref<AgentNodeData[]>,
|
|
heroId: Ref<string>,
|
|
isActive: (id: string) => boolean,
|
|
) {
|
|
// ── Layout ──
|
|
const cardPositions = ref<Record<string, CardBox>>({})
|
|
const svgWidth = ref(0)
|
|
const svgHeight = ref(0)
|
|
|
|
const childAgents = computed(() => agents.value.filter(a => a.id !== heroId.value))
|
|
|
|
function updatePositions() {
|
|
const el = networkRef.value
|
|
if (!el) return
|
|
const rect = el.getBoundingClientRect()
|
|
svgWidth.value = rect.width
|
|
svgHeight.value = rect.height
|
|
|
|
const cards = el.querySelectorAll('[data-agent-id]')
|
|
const positions: Record<string, CardBox> = {}
|
|
cards.forEach(card => {
|
|
const id = card.getAttribute('data-agent-id')
|
|
if (!id) return
|
|
const r = card.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
|
|
}
|
|
|
|
// ── Connection paths ──
|
|
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
|
|
const result: Record<string, ConnectionPath | null> = {}
|
|
const pos = cardPositions.value
|
|
const iris = pos[heroId.value]
|
|
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.38 + t * 0.24)
|
|
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 + 70
|
|
const cp2x = endX + (isLeftColumn ? 35 : -35)
|
|
const cp2y = endY - 10
|
|
|
|
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
|
|
result[agent.id] = { d, length: 0 }
|
|
}
|
|
return result
|
|
})
|
|
|
|
// ── Path refs (template ref functions) ──
|
|
const pathElements = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseElements2 = ref<Record<string, SVGPathElement | null>>({})
|
|
const pulseOffsets = ref<Record<string, number>>({})
|
|
const pulseOffsets2 = 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
|
|
}
|
|
}
|
|
|
|
function storePulseRef2(id: string) {
|
|
return (el: SVGPathElement | null) => {
|
|
pulseElements2.value[id] = el
|
|
}
|
|
}
|
|
|
|
// ── Pulse animation ──
|
|
let animFrameId: number | null = null
|
|
let lastAnimTime = 0
|
|
const speeds: Record<string, number> = {}
|
|
|
|
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', `40 ${p.length}`)
|
|
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
}
|
|
const pulseEl2 = pulseElements2.value[id]
|
|
if (pulseEl2 && p && p.length > 0) {
|
|
if (pulseOffsets2.value[id] === undefined) {
|
|
pulseOffsets2.value[id] = 0
|
|
}
|
|
pulseEl2.setAttribute('stroke-dasharray', `40 ${p.length}`)
|
|
pulseEl2.setAttribute('stroke-dashoffset', String(-pulseOffsets2.value[id]))
|
|
}
|
|
}
|
|
}
|
|
|
|
function startPulseAnimation() {
|
|
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
|
|
if (pulseOffsets.value[id] === undefined) pulseOffsets.value[id] = 0
|
|
if (pulseOffsets2.value[id] === undefined) pulseOffsets2.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 pulseEl2 = pulseElements2.value[id]
|
|
const p = connectionPaths.value[id]
|
|
if (!pathEl || !pulseEl || !p) continue
|
|
|
|
const len = p.length
|
|
if (len <= 0) continue
|
|
|
|
const speed = speeds[id] ?? len / 3000
|
|
const cycleLen = len + 40
|
|
|
|
// Pulse 1
|
|
const currentOffset = pulseOffsets.value[id] ?? 0
|
|
const newOffset = currentOffset + speed * dt
|
|
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
|
|
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
|
|
// Pulse 2 (offset by half cycle)
|
|
if (pulseEl2) {
|
|
const offset2 = (pulseOffsets.value[id] + cycleLen / 2) % cycleLen
|
|
pulseOffsets2.value[id] = offset2
|
|
pulseEl2.setAttribute('stroke-dashoffset', String(-offset2))
|
|
}
|
|
}
|
|
|
|
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()
|
|
requestAnimationFrame(() => {
|
|
refreshPathLengths()
|
|
})
|
|
})
|
|
if (networkRef.value) {
|
|
resizeObserver.observe(networkRef.value)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopPulseAnimation()
|
|
resizeObserver?.disconnect()
|
|
})
|
|
|
|
return {
|
|
cardPositions,
|
|
svgWidth,
|
|
svgHeight,
|
|
childAgents,
|
|
connectionPaths,
|
|
pathElements,
|
|
pulseElements,
|
|
pulseElements2,
|
|
pulseOffsets,
|
|
pulseOffsets2,
|
|
storePathRef,
|
|
storePulseRef,
|
|
storePulseRef2,
|
|
updatePositions,
|
|
refreshPathLengths,
|
|
}
|
|
}
|