57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
644 lines
23 KiB
TypeScript
644 lines
23 KiB
TypeScript
/**
|
|
* @file AgentCollaborationNetwork.tsx
|
|
* @description Defines the AgentCollaborationNetwork React component that visualizes the collaboration between different agent types in a directed graph format using D3.js. The component takes in effectiveness data for each agent type and the edges representing their interactions, and renders an interactive force-directed graph where nodes represent agent types and edges represent the frequency of sequential runs. The graph includes tooltips for detailed information on hover and a legend for clarity.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Observability
|
|
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
*
|
|
* ## Public surface
|
|
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **AgentCollaborationNetworkProps**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **AgentCollaborationNetwork**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { useRef, useEffect, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import * as d3 from "d3";
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
|
|
export interface AgentCollaborationNetworkProps {
|
|
effectiveness: Array<{
|
|
subagent_type: string;
|
|
total: number;
|
|
completed: number;
|
|
errors: number;
|
|
sessions: number;
|
|
successRate: number;
|
|
}>;
|
|
edges: Array<{ source: string; target: string; weight: number }>;
|
|
}
|
|
|
|
interface PipelineNode extends d3.SimulationNodeDatum {
|
|
id: string;
|
|
total: number;
|
|
sessions: number;
|
|
successRate: number;
|
|
colorIndex: number;
|
|
}
|
|
|
|
interface PipelineLink extends d3.SimulationLinkDatum<PipelineNode> {
|
|
weight: number;
|
|
label: string;
|
|
}
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
|
|
const PALETTE = [
|
|
"#6366f1",
|
|
"#3b82f6",
|
|
"#22c55e",
|
|
"#a855f7",
|
|
"#f59e0b",
|
|
"#ec4899",
|
|
"#06b6d4",
|
|
"#f97316",
|
|
"#ef4444",
|
|
"#14b8a6",
|
|
];
|
|
|
|
const STROKE_PALETTE = [
|
|
"#818cf8",
|
|
"#60a5fa",
|
|
"#4ade80",
|
|
"#c084fc",
|
|
"#fbbf24",
|
|
"#f472b6",
|
|
"#22d3ee",
|
|
"#fb923c",
|
|
"#f87171",
|
|
"#2dd4bf",
|
|
];
|
|
|
|
const MIN_R = 20;
|
|
const MAX_R = 44;
|
|
|
|
// ── Safe tooltip DOM builder ──
|
|
|
|
function appendTooltipRow(parent: HTMLElement, label: string, value: string) {
|
|
const row = document.createElement("div");
|
|
row.style.cssText = "display:flex;justify-content:space-between;gap:16px;font-size:11px";
|
|
const lbl = document.createElement("span");
|
|
lbl.style.color = "#64748b";
|
|
lbl.textContent = label;
|
|
const val = document.createElement("span");
|
|
val.style.cssText = "color:#cbd5e1;font-weight:500";
|
|
val.textContent = value;
|
|
row.appendChild(lbl);
|
|
row.appendChild(val);
|
|
parent.appendChild(row);
|
|
}
|
|
|
|
function appendTooltipDescription(parent: HTMLElement, text: string) {
|
|
const p = document.createElement("p");
|
|
p.style.cssText =
|
|
"font-size:11px;color:#94a3b8;line-height:1.45;margin:8px 0 0;padding-top:8px;border-top:1px solid #2a2a4a";
|
|
p.textContent = text;
|
|
parent.appendChild(p);
|
|
}
|
|
|
|
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
|
|
|
function describeNodeRole(d: PipelineNode, t: TFn): string {
|
|
const sr = Math.round(d.successRate);
|
|
let healthKey: string;
|
|
if (sr >= 95) healthKey = "pipeline.tooltip.health.perfect";
|
|
else if (sr >= 80) healthKey = "pipeline.tooltip.health.healthy";
|
|
else if (sr >= 50) healthKey = "pipeline.tooltip.health.shaky";
|
|
else healthKey = "pipeline.tooltip.health.failing";
|
|
|
|
return t("pipeline.tooltip.nodeDescFmt", {
|
|
id: d.id,
|
|
total: d.total,
|
|
sessions: d.sessions,
|
|
rate: sr,
|
|
health: t(healthKey),
|
|
});
|
|
}
|
|
|
|
function showTooltip(
|
|
el: HTMLDivElement,
|
|
d: PipelineNode,
|
|
x: number,
|
|
y: number,
|
|
t: TFn,
|
|
totalSpawns: number
|
|
) {
|
|
el.textContent = "";
|
|
|
|
const title = document.createElement("p");
|
|
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
|
|
title.textContent = d.id;
|
|
el.appendChild(title);
|
|
|
|
const subtitle = document.createElement("p");
|
|
subtitle.style.cssText =
|
|
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
|
subtitle.textContent = t("pipeline.tooltip.agentType");
|
|
el.appendChild(subtitle);
|
|
|
|
const sharePct = totalSpawns > 0 ? `${((d.total / totalSpawns) * 100).toFixed(1)}%` : "-";
|
|
|
|
const rows: [string, string][] = [
|
|
[t("pipeline.spawned"), String(d.total) + t("pipeline.spawns")],
|
|
[t("pipeline.tooltip.shareOfAllSpawns"), sharePct],
|
|
[t("pipeline.inSessions"), String(d.sessions)],
|
|
[t("effectiveness.success"), Math.round(d.successRate) + "%"],
|
|
];
|
|
for (const [label, value] of rows) {
|
|
appendTooltipRow(el, label, value);
|
|
}
|
|
|
|
appendTooltipDescription(el, describeNodeRole(d, t));
|
|
|
|
el.style.maxWidth = "320px";
|
|
positionTooltipAt(el, x, y);
|
|
}
|
|
|
|
/**
|
|
* Position a tooltip so its top-right corner sits near (x, y), but clamped to
|
|
* the viewport so it never disappears behind the sidebar or the right edge.
|
|
* Sets opacity to 1 to fade the tooltip in via its CSS transition.
|
|
*/
|
|
function positionTooltipAt(el: HTMLDivElement, x: number, y: number) {
|
|
el.style.opacity = "0";
|
|
const w = el.offsetWidth || 280;
|
|
const h = el.offsetHeight || 160;
|
|
const margin = 8;
|
|
|
|
// Default: place just below-right of the cursor
|
|
let left = x + 14;
|
|
let top = y + 14;
|
|
|
|
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
|
if (left < margin) left = margin;
|
|
if (top + h > window.innerHeight - margin) top = y - h - 14;
|
|
if (top < margin) top = margin;
|
|
|
|
el.style.left = `${left}px`;
|
|
el.style.top = `${top}px`;
|
|
el.style.transform = "";
|
|
// Trigger fade-in on the next frame for a smooth transition.
|
|
requestAnimationFrame(() => {
|
|
el.style.opacity = "1";
|
|
});
|
|
}
|
|
|
|
// ── Component ──────────────────────────────────────────────────────────────────
|
|
|
|
export function AgentCollaborationNetwork({
|
|
effectiveness,
|
|
edges,
|
|
}: AgentCollaborationNetworkProps) {
|
|
const { t } = useTranslation("workflows");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const simulationRef = useRef<d3.Simulation<PipelineNode, PipelineLink> | null>(null);
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Memoize so D3 effect only reruns when props change, not on every render
|
|
const { nodes, links, isEmpty } = useMemo(() => {
|
|
const nodeMap = new Map<string, PipelineNode>();
|
|
effectiveness.forEach((item, i) => {
|
|
nodeMap.set(item.subagent_type, {
|
|
id: item.subagent_type,
|
|
total: item.total,
|
|
sessions: item.sessions,
|
|
successRate: item.successRate,
|
|
colorIndex: i % PALETTE.length,
|
|
});
|
|
});
|
|
|
|
const seen = new Set<string>();
|
|
const links: PipelineLink[] = [];
|
|
for (const e of edges) {
|
|
if (e.source === e.target) continue;
|
|
if (!nodeMap.has(e.source) || !nodeMap.has(e.target)) continue;
|
|
const key = `${e.source}→${e.target}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
links.push({
|
|
source: e.source,
|
|
target: e.target,
|
|
weight: e.weight,
|
|
label: `${e.weight}x`,
|
|
});
|
|
}
|
|
|
|
const nodes = [...nodeMap.values()];
|
|
return { nodes, links, isEmpty: nodes.length === 0 || links.length === 0 };
|
|
}, [effectiveness, edges]);
|
|
|
|
// D3 simulation - only depends on memoized data
|
|
useEffect(() => {
|
|
const svg = svgRef.current;
|
|
const container = containerRef.current;
|
|
if (!svg || !container || isEmpty) return;
|
|
|
|
simulationRef.current?.stop();
|
|
|
|
const width = container.clientWidth;
|
|
const height = Math.max(450, Math.min(650, width * 0.6));
|
|
|
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
svg.style.width = `${width}px`;
|
|
svg.style.height = `${height}px`;
|
|
|
|
const root = d3.select(svg);
|
|
root.selectAll("*").remove();
|
|
|
|
// Arrow marker
|
|
const defs = root.append("defs");
|
|
defs
|
|
.append("marker")
|
|
.attr("id", "arrowhead")
|
|
.attr("viewBox", "0 0 10 6")
|
|
.attr("refX", 10)
|
|
.attr("refY", 3)
|
|
.attr("markerWidth", 8)
|
|
.attr("markerHeight", 5)
|
|
.attr("orient", "auto")
|
|
.append("path")
|
|
.attr("d", "M0,0 L10,3 L0,6 Z")
|
|
.attr("fill", "#64748b");
|
|
|
|
// Clone data
|
|
const simNodes = nodes.map((n) => ({ ...n }));
|
|
const nodeById = new Map(simNodes.map((n) => [n.id, n]));
|
|
const simLinks: PipelineLink[] = links.map((l) => ({
|
|
...l,
|
|
source: nodeById.get(l.source as string) ?? (l.source as string),
|
|
target: nodeById.get(l.target as string) ?? (l.target as string),
|
|
}));
|
|
|
|
// Scales
|
|
const ext = d3.extent(simNodes, (n) => n.total) as [number, number];
|
|
const rScale = d3
|
|
.scaleSqrt()
|
|
.domain([Math.max(1, ext[0] ?? 1), Math.max(2, ext[1] ?? 2)])
|
|
.range([MIN_R, MAX_R])
|
|
.clamp(true);
|
|
|
|
const wExt = d3.extent(simLinks, (l) => l.weight) as [number, number];
|
|
const strokeScale = d3
|
|
.scaleLinear()
|
|
.domain([Math.max(1, wExt[0] ?? 1), Math.max(2, wExt[1] ?? 2)])
|
|
.range([1.5, 5])
|
|
.clamp(true);
|
|
|
|
// ── Links (paths for curves) ──
|
|
const linkGroup = root.append("g");
|
|
const linkEls = linkGroup
|
|
.selectAll<SVGPathElement, PipelineLink>("path")
|
|
.data(simLinks)
|
|
.join("path")
|
|
.attr("fill", "none")
|
|
.attr("stroke", "#64748b")
|
|
.attr("stroke-opacity", 0.55)
|
|
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)))
|
|
.attr("marker-end", "url(#arrowhead)");
|
|
|
|
// ── Edge labels ──
|
|
const labelGroup = root.append("g");
|
|
const edgeLabels = labelGroup
|
|
.selectAll<SVGTextElement, PipelineLink>("text")
|
|
.data(simLinks)
|
|
.join("text")
|
|
.attr("text-anchor", "middle")
|
|
.attr("fill", "#94a3b8")
|
|
.attr("font-size", "9px")
|
|
.attr("font-weight", "600")
|
|
.attr("font-family", "Inter, sans-serif")
|
|
.attr("pointer-events", "none")
|
|
.text((d) => d.label);
|
|
|
|
// ── Nodes ──
|
|
const nodeGroup = root.append("g");
|
|
const nodeEls = nodeGroup
|
|
.selectAll<SVGGElement, PipelineNode>("g")
|
|
.data(simNodes, (d) => d.id)
|
|
.join("g")
|
|
.attr("cursor", "grab");
|
|
|
|
nodeEls
|
|
.append("circle")
|
|
.attr("r", (d) => rScale(d.total))
|
|
.attr("fill", (d) => PALETTE[d.colorIndex] ?? "#6366f1")
|
|
.attr("fill-opacity", 0.8)
|
|
.attr("stroke", (d) => STROKE_PALETTE[d.colorIndex] ?? "#818cf8")
|
|
.attr("stroke-width", 2);
|
|
|
|
nodeEls
|
|
.append("text")
|
|
.attr("text-anchor", "middle")
|
|
.attr("dy", (d) => rScale(d.total) + 14)
|
|
.attr("fill", "#cbd5e1")
|
|
.attr("font-size", "10px")
|
|
.attr("font-weight", "500")
|
|
.attr("font-family", "Inter, sans-serif")
|
|
.attr("pointer-events", "none")
|
|
.text((d) => (d.id.length > 16 ? d.id.slice(0, 14) + "\u2026" : d.id));
|
|
|
|
// ── Invisible wider hit areas for edge hover ──
|
|
const hitGroup = root.append("g");
|
|
const linkHits = hitGroup
|
|
.selectAll<SVGPathElement, PipelineLink>("path")
|
|
.data(simLinks)
|
|
.join("path")
|
|
.attr("fill", "none")
|
|
.attr("stroke", "transparent")
|
|
.attr("stroke-width", 16)
|
|
.attr("cursor", "pointer");
|
|
|
|
// ── Hover - pure DOM, zero React re-renders ──
|
|
const tipEl = tooltipRef.current;
|
|
|
|
// Edge hover
|
|
const totalSpawns = simNodes.reduce((s, n) => s + n.total, 0);
|
|
|
|
linkHits
|
|
.on("mouseenter", (event: MouseEvent, d: PipelineLink) => {
|
|
const src = d.source as PipelineNode;
|
|
const tgt = d.target as PipelineNode;
|
|
// Highlight this edge
|
|
linkEls
|
|
.attr("stroke-opacity", (l) => (l === d ? 1 : 0.1))
|
|
.attr("stroke-width", (l) =>
|
|
l === d ? Math.max(3, strokeScale(l.weight) + 1) : Math.max(1.5, strokeScale(l.weight))
|
|
);
|
|
edgeLabels.attr("fill-opacity", (l) => (l === d ? 1 : 0.15));
|
|
|
|
if (tipEl) {
|
|
tipEl.textContent = "";
|
|
const title = document.createElement("p");
|
|
title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0 0 2px";
|
|
title.textContent = `${src.id} \u2192 ${tgt.id}`;
|
|
tipEl.appendChild(title);
|
|
|
|
const subtitle = document.createElement("p");
|
|
subtitle.style.cssText =
|
|
"font-size:10px;color:#64748b;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em";
|
|
subtitle.textContent = t("pipeline.tooltip.edge");
|
|
tipEl.appendChild(subtitle);
|
|
|
|
const shareOfSrc = src.total > 0 ? `${((d.weight / src.total) * 100).toFixed(1)}%` : "-";
|
|
const shareOfTgt = tgt.total > 0 ? `${((d.weight / tgt.total) * 100).toFixed(1)}%` : "-";
|
|
|
|
const rows: [string, string][] = [
|
|
[t("pipeline.tooltip.sequentialPairs"), `${d.weight}\u00d7`],
|
|
[t("pipeline.tooltip.shareOfSrcFmt", { source: src.id }), shareOfSrc],
|
|
[t("pipeline.tooltip.shareOfTgtFmt", { target: tgt.id }), shareOfTgt],
|
|
[t("pipeline.tooltip.totalSpawnsFmt", { id: src.id }), String(src.total)],
|
|
[t("pipeline.tooltip.totalSpawnsFmt", { id: tgt.id }), String(tgt.total)],
|
|
];
|
|
for (const [label, value] of rows) {
|
|
appendTooltipRow(tipEl, label, value);
|
|
}
|
|
|
|
appendTooltipDescription(
|
|
tipEl,
|
|
t("pipeline.tooltip.edgeDescFmt", {
|
|
source: src.id,
|
|
target: tgt.id,
|
|
count: d.weight,
|
|
})
|
|
);
|
|
|
|
tipEl.style.maxWidth = "320px";
|
|
positionTooltipAt(tipEl, event.clientX, event.clientY);
|
|
}
|
|
})
|
|
.on("mouseleave", () => {
|
|
linkEls
|
|
.attr("stroke-opacity", 0.55)
|
|
.attr("stroke-width", (d) => Math.max(1.5, strokeScale(d.weight)));
|
|
edgeLabels.attr("fill-opacity", 1);
|
|
if (tipEl) tipEl.style.opacity = "0";
|
|
});
|
|
|
|
// Node hover
|
|
nodeEls
|
|
.on("mouseenter", (event: MouseEvent, d: PipelineNode) => {
|
|
linkEls.attr("stroke-opacity", (l) => {
|
|
const s = (l.source as PipelineNode).id;
|
|
const t = (l.target as PipelineNode).id;
|
|
return s === d.id || t === d.id ? 0.9 : 0.08;
|
|
});
|
|
edgeLabels.attr("fill-opacity", (l) => {
|
|
const s = (l.source as PipelineNode).id;
|
|
const t = (l.target as PipelineNode).id;
|
|
return s === d.id || t === d.id ? 1 : 0.15;
|
|
});
|
|
d3.select(event.currentTarget as SVGGElement)
|
|
.select("circle")
|
|
.attr("stroke-width", 4);
|
|
if (tipEl) showTooltip(tipEl, d, event.clientX, event.clientY, t, totalSpawns);
|
|
})
|
|
.on("mouseleave", () => {
|
|
linkEls.attr("stroke-opacity", 0.55);
|
|
edgeLabels.attr("fill-opacity", 1);
|
|
nodeEls.selectAll("circle").attr("stroke-width", 2);
|
|
if (tipEl) tipEl.style.opacity = "0";
|
|
});
|
|
|
|
// ── Drag ──
|
|
const drag = d3
|
|
.drag<SVGGElement, PipelineNode>()
|
|
.on("start", (event, d) => {
|
|
if (!event.active) simulation.alphaTarget(0.12).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
})
|
|
.on("drag", (event, d) => {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
})
|
|
.on("end", (event, d) => {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
});
|
|
nodeEls.call(drag);
|
|
|
|
// ── Simulation ──
|
|
const simulation = d3
|
|
.forceSimulation<PipelineNode>(simNodes)
|
|
.force(
|
|
"link",
|
|
d3
|
|
.forceLink<PipelineNode, PipelineLink>(simLinks)
|
|
.id((d) => d.id)
|
|
.distance(250)
|
|
)
|
|
.force("charge", d3.forceManyBody<PipelineNode>().strength(-800))
|
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
|
.force(
|
|
"collision",
|
|
d3.forceCollide<PipelineNode>().radius((d) => rScale(d.total) + 30)
|
|
)
|
|
.force("x", d3.forceX(width / 2).strength(0.03))
|
|
.force("y", d3.forceY(height / 2).strength(0.03))
|
|
.alpha(0.5)
|
|
.on("tick", () => {
|
|
// Clamp nodes inside viewBox
|
|
for (const n of simNodes) {
|
|
const r = rScale(n.total) + 16;
|
|
n.x = Math.max(r, Math.min(width - r, n.x ?? width / 2));
|
|
n.y = Math.max(r, Math.min(height - r, n.y ?? height / 2));
|
|
}
|
|
|
|
// Build path for each edge (shared by visible + hit area)
|
|
const pathFor = (d: PipelineLink) => {
|
|
const s = d.source as PipelineNode;
|
|
const t = d.target as PipelineNode;
|
|
const sx = s.x ?? 0,
|
|
sy = s.y ?? 0;
|
|
const tx = t.x ?? 0,
|
|
ty = t.y ?? 0;
|
|
const dx = tx - sx,
|
|
dy = ty - sy;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const sr = rScale(s.total);
|
|
const tr = rScale(t.total) + 8;
|
|
const x1 = sx + (dx / dist) * sr;
|
|
const y1 = sy + (dy / dist) * sr;
|
|
const x2 = tx - (dx / dist) * tr;
|
|
const y2 = ty - (dy / dist) * tr;
|
|
const mx = (x1 + x2) / 2 - dy * 0.1;
|
|
const my = (y1 + y2) / 2 + dx * 0.1;
|
|
return `M${x1},${y1} Q${mx},${my} ${x2},${y2}`;
|
|
};
|
|
|
|
linkEls.attr("d", pathFor);
|
|
linkHits.attr("d", pathFor);
|
|
|
|
edgeLabels.each(function (d) {
|
|
const s = d.source as PipelineNode;
|
|
const t = d.target as PipelineNode;
|
|
const sx = s.x ?? 0,
|
|
sy = s.y ?? 0,
|
|
tx = t.x ?? 0,
|
|
ty = t.y ?? 0;
|
|
const ddx = tx - sx,
|
|
ddy = ty - sy;
|
|
d3.select(this)
|
|
.attr("x", (sx + tx) / 2 - ddy * 0.1)
|
|
.attr("y", (sy + ty) / 2 + ddx * 0.1 - 4);
|
|
});
|
|
|
|
nodeEls.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
|
});
|
|
|
|
simulationRef.current = simulation as d3.Simulation<PipelineNode, PipelineLink>;
|
|
return () => {
|
|
simulation.stop();
|
|
};
|
|
}, [nodes, links, isEmpty, t]);
|
|
|
|
if (isEmpty) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<p className="text-sm font-medium text-gray-400">{t("pipeline.noData")}</p>
|
|
<p className="text-xs text-gray-600 mt-1">{t("pipeline.noDataDesc")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleContainerLeave = () => {
|
|
const tip = tooltipRef.current;
|
|
if (tip) tip.style.opacity = "0";
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full relative" onMouseLeave={handleContainerLeave}>
|
|
<svg
|
|
ref={svgRef}
|
|
style={{ display: "block", width: "100%", background: "transparent" }}
|
|
aria-label={t("pipeline.ariaLabel")}
|
|
role="img"
|
|
onMouseLeave={handleContainerLeave}
|
|
/>
|
|
<div
|
|
ref={tooltipRef}
|
|
role="tooltip"
|
|
aria-hidden="true"
|
|
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
|
|
style={{
|
|
opacity: 0,
|
|
left: 0,
|
|
top: 0,
|
|
minWidth: 172,
|
|
transition: "opacity 120ms ease-out",
|
|
}}
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-3 mt-3 px-1">
|
|
<span className="text-[10px] text-gray-600 uppercase tracking-widest font-medium">
|
|
{t("pipeline.legend")}
|
|
</span>
|
|
{nodes.map((n) => (
|
|
<div key={n.id} className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
style={{
|
|
backgroundColor: PALETTE[n.colorIndex] ?? PALETTE[0],
|
|
border: `1.5px solid ${STROKE_PALETTE[n.colorIndex] ?? STROKE_PALETTE[0]}`,
|
|
}}
|
|
/>
|
|
<span className="text-[11px] text-gray-500">{n.id}</span>
|
|
</div>
|
|
))}
|
|
<div className="flex items-center gap-1.5 ml-2">
|
|
<svg width="20" height="8" className="flex-shrink-0">
|
|
<line x1="0" y1="4" x2="14" y2="4" stroke="#64748b" strokeWidth="1.5" />
|
|
<polygon points="14,1 20,4 14,7" fill="#64748b" />
|
|
</svg>
|
|
<span className="text-[11px] text-gray-500">{t("pipeline.legendDesc")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|