|
|
@@ -14,7 +14,6 @@ interface FlowChartProps {
|
|
|
goals: Goal[];
|
|
|
msgGroups?: Record<string, Message[]>;
|
|
|
onNodeClick?: (node: Goal, edge?: EdgeType) => void;
|
|
|
- onEdgeClick?: (edge: EdgeType) => void;
|
|
|
}
|
|
|
|
|
|
interface LayoutNode extends d3.HierarchyPointNode<Goal> {
|
|
|
@@ -25,8 +24,23 @@ interface LayoutNode extends d3.HierarchyPointNode<Goal> {
|
|
|
type TreeGoal = Goal & { children?: TreeGoal[] };
|
|
|
const VIRTUAL_ROOT_ID = "__VIRTUAL_ROOT__";
|
|
|
|
|
|
-export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onEdgeClick }) => {
|
|
|
- console.log("%c [ msgGroups ]-29", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
|
|
|
+export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick }) => {
|
|
|
+ // 确保 goals 中包含 END 节点
|
|
|
+ const displayGoals = useMemo(() => {
|
|
|
+ if (!goals) return [];
|
|
|
+ const hasEnd = goals.some((g) => g.id === "END");
|
|
|
+ if (hasEnd) return goals;
|
|
|
+ const endGoal: Goal = {
|
|
|
+ id: "END",
|
|
|
+ description: "终止",
|
|
|
+ status: "completed",
|
|
|
+ created_at: new Date().toISOString(),
|
|
|
+ reason: "",
|
|
|
+ };
|
|
|
+ return [...goals, endGoal];
|
|
|
+ }, [goals]);
|
|
|
+
|
|
|
+ console.log("%c [ goals ]-28", "font-size:13px; background:pink; color:#bf2c9f;", displayGoals);
|
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
|
|
|
@@ -82,8 +96,8 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
|
|
|
// 计算树布局坐标(横向)
|
|
|
const layoutData = useMemo(() => {
|
|
|
- if (!goals || goals.length === 0) return null;
|
|
|
- const root = buildHierarchy(goals);
|
|
|
+ if (!displayGoals || displayGoals.length === 0) return null;
|
|
|
+ const root = buildHierarchy(displayGoals);
|
|
|
const margin = { top: 60, right: 140, bottom: 60, left: 140 };
|
|
|
const treeLayout = d3
|
|
|
.tree<Goal>()
|
|
|
@@ -95,7 +109,7 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
links: treeData.links(),
|
|
|
margin,
|
|
|
};
|
|
|
- }, [goals, dimensions]);
|
|
|
+ }, [displayGoals, dimensions]);
|
|
|
|
|
|
// 选中节点到根的路径,用于高亮与弱化
|
|
|
const pathNodeIds = useMemo(() => {
|
|
|
@@ -115,18 +129,52 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
const nonVirtualLinks = layoutData.links.filter(
|
|
|
(link) => link.source.data.id !== VIRTUAL_ROOT_ID && link.target.data.id !== VIRTUAL_ROOT_ID,
|
|
|
);
|
|
|
- if (nonVirtualLinks.length > 0) return nonVirtualLinks;
|
|
|
+ // Remove the early return so we can merge nonVirtualLinks with manual sibling links
|
|
|
+ // if (nonVirtualLinks.length > 0) return nonVirtualLinks;
|
|
|
+
|
|
|
const nodeMap = new Map(layoutData.nodes.map((node) => [node.data.id, node]));
|
|
|
- const orderedNodes = goals.map((goal) => nodeMap.get(goal.id)).filter((node): node is LayoutNode => Boolean(node));
|
|
|
+ const orderedNodes = displayGoals
|
|
|
+ .map((goal) => nodeMap.get(goal.id))
|
|
|
+ .filter((node): node is LayoutNode => Boolean(node));
|
|
|
const links: d3.HierarchyPointLink<Goal>[] = [];
|
|
|
+
|
|
|
+ // 处理主链(Sibling 顺序连接)
|
|
|
for (let i = 0; i < orderedNodes.length - 1; i += 1) {
|
|
|
- links.push({
|
|
|
- source: orderedNodes[i],
|
|
|
- target: orderedNodes[i + 1],
|
|
|
- });
|
|
|
+ const current = orderedNodes[i];
|
|
|
+ const next = orderedNodes[i + 1];
|
|
|
+
|
|
|
+ // 如果是 END 节点前的节点,且该节点有子节点(非 END 的子节点),则不直接连接到 END
|
|
|
+ // 而是将子节点连接到 END
|
|
|
+ if (next.data.id === "END" && current.children && current.children.length > 0) {
|
|
|
+ // 过滤掉已经在 mainLinks 中的连接(理论上 current -> children 已经在 nonVirtualLinks 中)
|
|
|
+ // 这里添加 children -> END 的连接
|
|
|
+ current.children.forEach((child) => {
|
|
|
+ // 确保 child 不是 END(虽然 END 此时是 sibling)
|
|
|
+ if (child.data.id !== "END") {
|
|
|
+ links.push({
|
|
|
+ source: child as LayoutNode,
|
|
|
+ target: next,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ // 不添加 current -> END
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 避免重复:如果 nonVirtualLinks 中已经存在 current -> next(即父子关系),则跳过
|
|
|
+ const exists = nonVirtualLinks.some(
|
|
|
+ (l) => l.source.data.id === current.data.id && l.target.data.id === next.data.id,
|
|
|
+ );
|
|
|
+ if (!exists) {
|
|
|
+ links.push({
|
|
|
+ source: current,
|
|
|
+ target: next,
|
|
|
+ });
|
|
|
+ }
|
|
|
}
|
|
|
- return links;
|
|
|
- }, [layoutData, goals]);
|
|
|
+
|
|
|
+ return [...nonVirtualLinks, ...links];
|
|
|
+ }, [layoutData, displayGoals]);
|
|
|
|
|
|
// 节点点击:记录选中状态并回传对应边
|
|
|
const handleNodeClick = (node: LayoutNode) => {
|
|
|
@@ -143,17 +191,6 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
onNodeClick?.(node.data, edge);
|
|
|
};
|
|
|
|
|
|
- // 边点击:构造 Edge 类型回传
|
|
|
- const handleEdgeClick = (link: d3.HierarchyPointLink<Goal>) => {
|
|
|
- const edge: EdgeType = {
|
|
|
- id: `${link.source.data.id}-${link.target.data.id}`,
|
|
|
- source: link.source.data.id,
|
|
|
- target: link.target.data.id,
|
|
|
- label: "",
|
|
|
- };
|
|
|
- onEdgeClick?.(edge);
|
|
|
- };
|
|
|
-
|
|
|
// 当前选中节点的消息链
|
|
|
const selectedMessages = useMemo(() => {
|
|
|
if (!selectedNodeId) return [];
|
|
|
@@ -165,60 +202,54 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
if (!layoutData || !selectedNodeId || selectedMessages.length === 0) return null;
|
|
|
const anchorNode = layoutData.nodes.find((n) => n.data.id === selectedNodeId);
|
|
|
if (!anchorNode) return null;
|
|
|
-
|
|
|
- const startPt = { x: anchorNode.x, y: anchorNode.y };
|
|
|
+ const anchorX = anchorNode.x;
|
|
|
+ const anchorY = anchorNode.y;
|
|
|
const count = selectedMessages.length;
|
|
|
+ const sides = count + 2;
|
|
|
+ const radius = Math.max(90, 44 + count * 16 + Math.max(0, count - 4) * 6);
|
|
|
+ const centerX = anchorX;
|
|
|
+ const centerY = anchorY + radius;
|
|
|
+ const angleStep = (Math.PI * 2) / sides;
|
|
|
|
|
|
- const nextGoalLink = mainLinks.find((l) => l.source.data.id === selectedNodeId);
|
|
|
- // 如果没有下一个节点,则虚拟一个终点在右侧,形成自然的抛物线
|
|
|
- const endPt = nextGoalLink
|
|
|
- ? { x: nextGoalLink.target.x, y: nextGoalLink.target.y }
|
|
|
- : { x: startPt.x + 400 + Math.max(0, count - 4) * 80, y: startPt.y };
|
|
|
-
|
|
|
- // 计算悬链线控制点 (Quadratic Bezier)
|
|
|
- // 深度随节点数增加,确保不拥挤
|
|
|
- // 当节点数 > 4 时,额外增加深度以拉开间距
|
|
|
- const depth = 120 + count * 35 + Math.max(0, count - 4) * 40;
|
|
|
- const midX = (startPt.x + endPt.x) / 2;
|
|
|
- const bottomY = Math.max(startPt.y, endPt.y);
|
|
|
- const controlPt = { x: midX, y: bottomY + depth };
|
|
|
-
|
|
|
- const getQuadraticBezierPoint = (
|
|
|
- t: number,
|
|
|
- p0: { x: number; y: number },
|
|
|
- p1: { x: number; y: number },
|
|
|
- p2: { x: number; y: number },
|
|
|
- ) => {
|
|
|
- const oneMinusT = 1 - t;
|
|
|
+ const msgNodes = selectedMessages.map((message, index) => {
|
|
|
+ const angle = -Math.PI / 2 - angleStep * (index + 1);
|
|
|
return {
|
|
|
- x: oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x,
|
|
|
- y: oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y,
|
|
|
+ x: centerX + Math.cos(angle) * radius,
|
|
|
+ y: centerY + Math.sin(angle) * radius,
|
|
|
+ message,
|
|
|
};
|
|
|
+ });
|
|
|
+
|
|
|
+ const nextGoalLink = mainLinks.find((l) => l.source.data.id === selectedNodeId);
|
|
|
+ const nextGoalTarget = nextGoalLink?.target;
|
|
|
+
|
|
|
+ // 节点半高度,用于计算下边界连接点
|
|
|
+ const nodeHalfH = 5;
|
|
|
+
|
|
|
+ const paths: { d: string; dashed?: boolean; terminal?: boolean }[] = [];
|
|
|
+ const buildSegment = (sx: number, sy: number, tx: number, ty: number) => {
|
|
|
+ const controlX = (sx + tx) / 2 - 40;
|
|
|
+ const controlY = Math.max(sy, ty) - 30;
|
|
|
+ return `M${sx},${sy} Q${controlX},${controlY} ${tx},${ty}`;
|
|
|
};
|
|
|
|
|
|
- // 采样点:起点 -> 消息点... -> 终点
|
|
|
- const totalSegments = count + 1;
|
|
|
- const points = [startPt];
|
|
|
- const msgNodes = [];
|
|
|
-
|
|
|
- for (let i = 1; i <= count; i += 1) {
|
|
|
- const t = i / totalSegments;
|
|
|
- const pos = getQuadraticBezierPoint(t, startPt, controlPt, endPt);
|
|
|
- msgNodes.push({
|
|
|
- x: pos.x,
|
|
|
- y: pos.y,
|
|
|
- message: selectedMessages[i - 1],
|
|
|
- });
|
|
|
- points.push(pos);
|
|
|
+ for (let i = -1; i < msgNodes.length - 1; i += 1) {
|
|
|
+ // 起始点:如果是第一个点(i < 0),则从 anchorNode 的下边界开始 (anchorY + nodeHalfH)
|
|
|
+ // 否则从上一个 msgNode 开始
|
|
|
+ const from = i < 0 ? { x: anchorX, y: anchorY + nodeHalfH } : msgNodes[i];
|
|
|
+ const to = msgNodes[i + 1];
|
|
|
+ const d = buildSegment(from.x, from.y, to.x, to.y);
|
|
|
+ paths.push({ d, dashed: true, terminal: true });
|
|
|
}
|
|
|
- points.push(endPt);
|
|
|
|
|
|
- const paths: { d: string; dashed?: boolean; terminal?: boolean }[] = [];
|
|
|
- for (let i = 0; i < points.length - 1; i += 1) {
|
|
|
- const p1 = points[i];
|
|
|
- const p2 = points[i + 1];
|
|
|
- // 使用直线段连接采样点,形成带箭头的折线链,整体逼近曲线
|
|
|
- const d = `M${p1.x},${p1.y} L${p2.x},${p2.y}`;
|
|
|
+ if (nextGoalTarget && msgNodes.length > 0) {
|
|
|
+ const last = msgNodes[msgNodes.length - 1];
|
|
|
+ const tx = nextGoalTarget.x;
|
|
|
+ // 结束点:连接到 nextGoalTarget 的下边界 (ty + nodeHalfH)
|
|
|
+ const ty = nextGoalTarget.y + nodeHalfH;
|
|
|
+ const controlX = (last.x + tx) / 2;
|
|
|
+ const controlY = Math.max(last.y, ty) - 2;
|
|
|
+ const d = `M${last.x},${last.y} Q${controlX},${controlY} ${tx},${ty}`;
|
|
|
paths.push({ d, dashed: true, terminal: true });
|
|
|
}
|
|
|
|
|
|
@@ -303,11 +334,13 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
const dimmed = false;
|
|
|
return (
|
|
|
<Edge
|
|
|
- key={index}
|
|
|
+ key={`edge-line-${index}`}
|
|
|
link={link}
|
|
|
+ label={link.target.data.reason}
|
|
|
highlighted={isInPath}
|
|
|
dimmed={dimmed}
|
|
|
- onClick={() => handleEdgeClick(link)}
|
|
|
+ onClick={() => handleNodeClick(link.source)}
|
|
|
+ mode="line"
|
|
|
/>
|
|
|
);
|
|
|
})}
|
|
|
@@ -330,6 +363,30 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
);
|
|
|
})}
|
|
|
</g>
|
|
|
+ <g className={styles.links}>
|
|
|
+ {mainLinks
|
|
|
+ .filter(
|
|
|
+ (link) => link.source.data.id !== VIRTUAL_ROOT_ID && link.target.data.id !== VIRTUAL_ROOT_ID,
|
|
|
+ )
|
|
|
+ .map((link, index) => {
|
|
|
+ const isInPath =
|
|
|
+ pathNodeIds.size > 0 &&
|
|
|
+ pathNodeIds.has(link.source.data.id) &&
|
|
|
+ pathNodeIds.has(link.target.data.id);
|
|
|
+ const dimmed = false;
|
|
|
+ return (
|
|
|
+ <Edge
|
|
|
+ key={`edge-label-${index}`}
|
|
|
+ link={link}
|
|
|
+ label={link.target.data.reason}
|
|
|
+ highlighted={isInPath}
|
|
|
+ dimmed={dimmed}
|
|
|
+ onClick={() => handleNodeClick(link.source)}
|
|
|
+ mode="label"
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </g>
|
|
|
{messageOverlay && (
|
|
|
<g>
|
|
|
{messageOverlay.paths.map((p, idx) => (
|
|
|
@@ -347,28 +404,24 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
))}
|
|
|
{messageOverlay.msgNodes.map((mn, idx) =>
|
|
|
(() => {
|
|
|
- const fullText = mn.message.description || mn.message.content || "";
|
|
|
- const shortText = fullText.length > 6 ? `${fullText.slice(0, 6)}...` : fullText;
|
|
|
- const isLast = idx === messageOverlay.msgNodes.length - 1;
|
|
|
+ const content = mn.message.content;
|
|
|
+ let contentStr = "";
|
|
|
+ if (typeof content === "string") {
|
|
|
+ contentStr = content;
|
|
|
+ } else if (content) {
|
|
|
+ contentStr = content.text || JSON.stringify(content);
|
|
|
+ }
|
|
|
+ const fullText = mn.message.description || contentStr || "";
|
|
|
+ const truncateMiddle = (text: string, limit: number) => {
|
|
|
+ if (text.length <= limit) return text;
|
|
|
+ return `${text.slice(0, 2)}...${text.slice(-2)}`;
|
|
|
+ };
|
|
|
+ const shortText = truncateMiddle(fullText, 6);
|
|
|
return (
|
|
|
<g
|
|
|
key={`msg-node-${idx}`}
|
|
|
transform={`translate(${mn.x},${mn.y})`}
|
|
|
>
|
|
|
- {isLast ? (
|
|
|
- <path
|
|
|
- d="M-2,-4 L6,0 L-2,4 Z"
|
|
|
- fill="#7aa0d6"
|
|
|
- />
|
|
|
- ) : (
|
|
|
- <circle
|
|
|
- r={4.5}
|
|
|
- fill="#7aa0d6"
|
|
|
- stroke="#ffffff"
|
|
|
- strokeWidth={2}
|
|
|
- />
|
|
|
- )}
|
|
|
-
|
|
|
<Tooltip content={fullText}>
|
|
|
<text
|
|
|
x={0}
|