|
|
@@ -9,8 +9,8 @@
|
|
|
* 5. 线条粗细根据嵌套层级递减
|
|
|
*/
|
|
|
|
|
|
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
-import type { FC } from "react";
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
|
|
+import type { ForwardRefRenderFunction } from "react";
|
|
|
import type { Goal } from "../../types/goal";
|
|
|
import type { Edge as EdgeType, Message } from "../../types/message";
|
|
|
import { ArrowMarkers } from "./components/ArrowMarkers";
|
|
|
@@ -27,6 +27,14 @@ interface FlowChartProps {
|
|
|
onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void; // 子追踪点击回调
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * FlowChart 组件对外暴露的引用接口
|
|
|
+ */
|
|
|
+export interface FlowChartRef {
|
|
|
+ expandAll: () => void;
|
|
|
+ collapseAll: () => void;
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* 子追踪条目类型
|
|
|
*/
|
|
|
@@ -61,7 +69,10 @@ interface LayoutEdge {
|
|
|
children?: LayoutNode[]; // 折叠时隐藏的子节点列表
|
|
|
}
|
|
|
|
|
|
-export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onSubTraceClick }) => {
|
|
|
+const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
|
|
|
+ { goals, msgGroups = {}, onNodeClick, onSubTraceClick },
|
|
|
+ ref,
|
|
|
+) => {
|
|
|
console.log("%c [ msgGroups ]-33", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
|
|
|
// 过滤掉有父节点的 goals,只保留主链节点
|
|
|
goals = goals.filter((g) => !g.parent_id);
|
|
|
@@ -489,6 +500,26 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
return { nodes, edges };
|
|
|
}, [displayGoals, dimensions, msgGroups, collapsedEdges]);
|
|
|
|
|
|
+ // 暴露给父组件的方法
|
|
|
+ useImperativeHandle(
|
|
|
+ ref,
|
|
|
+ () => ({
|
|
|
+ expandAll: () => {
|
|
|
+ setCollapsedEdges(new Set());
|
|
|
+ },
|
|
|
+ collapseAll: () => {
|
|
|
+ const allCollapsible = new Set<string>();
|
|
|
+ layoutData.edges.forEach((edge) => {
|
|
|
+ if (edge.collapsible) {
|
|
|
+ allCollapsible.add(edge.id);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ setCollapsedEdges(allCollapsible);
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ [layoutData],
|
|
|
+ );
|
|
|
+
|
|
|
// 初始化折叠状态:只展开第一个主链节点(A->B)之间的内容
|
|
|
useEffect(() => {
|
|
|
if (initializedRef.current || !layoutData || layoutData.edges.length === 0) return;
|
|
|
@@ -724,203 +755,207 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
<div
|
|
|
className={styles.container}
|
|
|
ref={containerRef}
|
|
|
- style={{
|
|
|
- overflow: viewMode === "scroll" ? "auto" : "hidden",
|
|
|
- }}
|
|
|
>
|
|
|
- <svg
|
|
|
- ref={svgRef}
|
|
|
- viewBox={
|
|
|
- viewMode === "scroll"
|
|
|
- ? `200 0 ${contentSize.width} ${contentSize.height}`
|
|
|
- : `0 0 ${dimensions.width} ${dimensions.height}`
|
|
|
- }
|
|
|
- preserveAspectRatio="xMidYMid meet"
|
|
|
- className={`${styles.svg} ${isPanning ? styles.panning : ""}`}
|
|
|
+ <div
|
|
|
+ className={styles.scrollContainer}
|
|
|
style={{
|
|
|
- cursor: viewMode === "scroll" ? "default" : isPanning ? "grabbing" : "grab",
|
|
|
- width: viewMode === "scroll" ? "100%" : "100%",
|
|
|
- height: viewMode === "scroll" ? contentSize.height : "100%",
|
|
|
- minWidth: viewMode === "scroll" ? contentSize.width : undefined,
|
|
|
- minHeight: viewMode === "scroll" ? contentSize.height : undefined,
|
|
|
- }}
|
|
|
- onWheel={(event) => {
|
|
|
- if (viewMode === "scroll") return; // 滚动模式下使用原生滚动
|
|
|
-
|
|
|
- // 鼠标滚轮缩放
|
|
|
- event.preventDefault();
|
|
|
- const rect = svgRef.current?.getBoundingClientRect();
|
|
|
- if (!rect) return;
|
|
|
- const cursorX = event.clientX - rect.left;
|
|
|
- const cursorY = event.clientY - rect.top;
|
|
|
- const nextZoom = clampZoom(zoom * (event.deltaY > 0 ? 0.92 : 1.08));
|
|
|
- if (nextZoom === zoom) return;
|
|
|
- const scale = nextZoom / zoom;
|
|
|
- // 以鼠标位置为中心缩放
|
|
|
- setPanOffset((prev) => ({
|
|
|
- x: cursorX - (cursorX - prev.x) * scale,
|
|
|
- y: cursorY - (cursorY - prev.y) * scale,
|
|
|
- }));
|
|
|
- setZoom(nextZoom);
|
|
|
- }}
|
|
|
- onDoubleClick={() => resetView()} // 双击重置视图
|
|
|
- onMouseDown={(event) => {
|
|
|
- if (viewMode === "scroll") return; // 滚动模式下禁用拖拽
|
|
|
-
|
|
|
- // 开始平移
|
|
|
- if (event.button !== 0) return; // 只响应左键
|
|
|
- const target = event.target as Element;
|
|
|
- // 如果点击的是节点或连接线,不触发平移
|
|
|
- if (target.closest(`.${styles.nodes}`) || target.closest(`.${styles.links}`)) return;
|
|
|
- panStartRef.current = {
|
|
|
- x: event.clientX,
|
|
|
- y: event.clientY,
|
|
|
- originX: panOffset.x,
|
|
|
- originY: panOffset.y,
|
|
|
- };
|
|
|
- setIsPanning(true);
|
|
|
+ overflow: viewMode === "scroll" ? "auto" : "hidden",
|
|
|
}}
|
|
|
>
|
|
|
- <defs>
|
|
|
- <ArrowMarkers /> {/* 箭头标记定义 */}
|
|
|
- </defs>
|
|
|
-
|
|
|
- {/* 应用平移和缩放变换(仅在 panzoom 模式下) */}
|
|
|
- <g transform={viewMode === "scroll" ? undefined : `translate(${panOffset.x},${panOffset.y}) scale(${zoom})`}>
|
|
|
- {/* 绘制连接线 */}
|
|
|
- <g className={styles.links}>
|
|
|
- {visibleData.edges.map((edge) => {
|
|
|
- // 根据连接线类型选择路径
|
|
|
- const path =
|
|
|
- edge.type === "arc" && !edge.collapsed
|
|
|
- ? getArcPath(edge.source, edge.target, edge.level)
|
|
|
- : getLinePath(edge.source, edge.target);
|
|
|
-
|
|
|
- const strokeWidth = getStrokeWidth(edge.level); // 根据层级计算线条粗细
|
|
|
-
|
|
|
- // 根据节点类型决定颜色
|
|
|
- let color = "#2196F3"; // 默认蓝色
|
|
|
-
|
|
|
- // 判断连接线连接的节点类型
|
|
|
- const sourceIsMessage = edge.source.type === "message";
|
|
|
- const targetIsMessage = edge.target.type === "message";
|
|
|
- const sourceIsMainGoal = edge.source.type === "goal" && edge.source.level === 0;
|
|
|
- const targetIsMainGoal = edge.target.type === "goal" && edge.target.level === 0;
|
|
|
-
|
|
|
- if (sourceIsMessage || targetIsMessage) {
|
|
|
- // msgGroup 相关的连接线用灰色
|
|
|
- color = "#9E9E9E";
|
|
|
- } else if (sourceIsMainGoal && targetIsMainGoal) {
|
|
|
- // 主节点之间的连接线用绿色
|
|
|
- color = "#4CAF50";
|
|
|
- } else {
|
|
|
- // sub_goals 之间的连接线用蓝色
|
|
|
- color = "#2196F3";
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <g key={edge.id}>
|
|
|
- <path
|
|
|
- d={path}
|
|
|
- fill="none"
|
|
|
- stroke={color}
|
|
|
- strokeWidth={strokeWidth}
|
|
|
- markerEnd="url(#arrow-default)" // 箭头
|
|
|
- style={{ cursor: edge.collapsible ? "pointer" : "default" }} // 可折叠的显示手型光标
|
|
|
- onClick={() => edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态
|
|
|
- />
|
|
|
- {/* 折叠状态提示徽章 */}
|
|
|
- {edge.collapsed && (
|
|
|
- <g
|
|
|
- transform={`translate(${(edge.source.x + edge.target.x) / 2},${
|
|
|
- (edge.source.y + edge.target.y) / 2
|
|
|
- })`}
|
|
|
- onClick={(e) => {
|
|
|
- e.stopPropagation();
|
|
|
- toggleCollapse(edge.id);
|
|
|
- }}
|
|
|
- style={{ cursor: "pointer" }}
|
|
|
- >
|
|
|
- <circle
|
|
|
- r={10}
|
|
|
- fill="#FFFFFF"
|
|
|
- stroke={color}
|
|
|
- strokeWidth={1}
|
|
|
- />
|
|
|
- <text
|
|
|
- x={0}
|
|
|
- y={4}
|
|
|
- fontSize={10}
|
|
|
- fill={color}
|
|
|
- textAnchor="middle"
|
|
|
- fontWeight="bold"
|
|
|
+ <svg
|
|
|
+ ref={svgRef}
|
|
|
+ viewBox={
|
|
|
+ viewMode === "scroll"
|
|
|
+ ? `${contentSize.width / 2} 0 ${contentSize.width} ${contentSize.height}`
|
|
|
+ : `0 0 ${dimensions.width} ${dimensions.height}`
|
|
|
+ }
|
|
|
+ preserveAspectRatio="xMidYMid meet"
|
|
|
+ className={`${styles.svg} ${isPanning ? styles.panning : ""}`}
|
|
|
+ style={{
|
|
|
+ cursor: viewMode === "scroll" ? "default" : isPanning ? "grabbing" : "grab",
|
|
|
+ width: viewMode === "scroll" ? "100%" : "100%",
|
|
|
+ height: viewMode === "scroll" ? contentSize.height : "100%",
|
|
|
+ minWidth: viewMode === "scroll" ? contentSize.width : undefined,
|
|
|
+ minHeight: viewMode === "scroll" ? contentSize.height : undefined,
|
|
|
+ }}
|
|
|
+ onWheel={(event) => {
|
|
|
+ if (viewMode === "scroll") return; // 滚动模式下使用原生滚动
|
|
|
+
|
|
|
+ // 鼠标滚轮缩放
|
|
|
+ event.preventDefault();
|
|
|
+ const rect = svgRef.current?.getBoundingClientRect();
|
|
|
+ if (!rect) return;
|
|
|
+ const cursorX = event.clientX - rect.left;
|
|
|
+ const cursorY = event.clientY - rect.top;
|
|
|
+ const nextZoom = clampZoom(zoom * (event.deltaY > 0 ? 0.92 : 1.08));
|
|
|
+ if (nextZoom === zoom) return;
|
|
|
+ const scale = nextZoom / zoom;
|
|
|
+ // 以鼠标位置为中心缩放
|
|
|
+ setPanOffset((prev) => ({
|
|
|
+ x: cursorX - (cursorX - prev.x) * scale,
|
|
|
+ y: cursorY - (cursorY - prev.y) * scale,
|
|
|
+ }));
|
|
|
+ setZoom(nextZoom);
|
|
|
+ }}
|
|
|
+ onDoubleClick={() => resetView()} // 双击重置视图
|
|
|
+ onMouseDown={(event) => {
|
|
|
+ if (viewMode === "scroll") return; // 滚动模式下禁用拖拽
|
|
|
+
|
|
|
+ // 开始平移
|
|
|
+ if (event.button !== 0) return; // 只响应左键
|
|
|
+ const target = event.target as Element;
|
|
|
+ // 如果点击的是节点或连接线,不触发平移
|
|
|
+ if (target.closest(`.${styles.nodes}`) || target.closest(`.${styles.links}`)) return;
|
|
|
+ panStartRef.current = {
|
|
|
+ x: event.clientX,
|
|
|
+ y: event.clientY,
|
|
|
+ originX: panOffset.x,
|
|
|
+ originY: panOffset.y,
|
|
|
+ };
|
|
|
+ setIsPanning(true);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <defs>
|
|
|
+ <ArrowMarkers /> {/* 箭头标记定义 */}
|
|
|
+ </defs>
|
|
|
+
|
|
|
+ {/* 应用平移和缩放变换(仅在 panzoom 模式下) */}
|
|
|
+ <g transform={viewMode === "scroll" ? undefined : `translate(${panOffset.x},${panOffset.y}) scale(${zoom})`}>
|
|
|
+ {/* 绘制连接线 */}
|
|
|
+ <g className={styles.links}>
|
|
|
+ {visibleData.edges.map((edge) => {
|
|
|
+ // 根据连接线类型选择路径
|
|
|
+ const path =
|
|
|
+ edge.type === "arc" && !edge.collapsed
|
|
|
+ ? getArcPath(edge.source, edge.target, edge.level)
|
|
|
+ : getLinePath(edge.source, edge.target);
|
|
|
+
|
|
|
+ const strokeWidth = getStrokeWidth(edge.level); // 根据层级计算线条粗细
|
|
|
+
|
|
|
+ // 根据节点类型决定颜色
|
|
|
+ let color = "#2196F3"; // 默认蓝色
|
|
|
+
|
|
|
+ // 判断连接线连接的节点类型
|
|
|
+ const sourceIsMessage = edge.source.type === "message";
|
|
|
+ const targetIsMessage = edge.target.type === "message";
|
|
|
+ const sourceIsMainGoal = edge.source.type === "goal" && edge.source.level === 0;
|
|
|
+ const targetIsMainGoal = edge.target.type === "goal" && edge.target.level === 0;
|
|
|
+
|
|
|
+ if (sourceIsMessage || targetIsMessage) {
|
|
|
+ // msgGroup 相关的连接线用灰色
|
|
|
+ color = "#9E9E9E";
|
|
|
+ } else if (sourceIsMainGoal && targetIsMainGoal) {
|
|
|
+ // 主节点之间的连接线用绿色
|
|
|
+ color = "#4CAF50";
|
|
|
+ } else {
|
|
|
+ // sub_goals 之间的连接线用蓝色
|
|
|
+ color = "#2196F3";
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <g key={edge.id}>
|
|
|
+ <path
|
|
|
+ d={path}
|
|
|
+ fill="none"
|
|
|
+ stroke={color}
|
|
|
+ strokeWidth={strokeWidth}
|
|
|
+ markerEnd="url(#arrow-default)" // 箭头
|
|
|
+ style={{ cursor: edge.collapsible ? "pointer" : "default" }} // 可折叠的显示手型光标
|
|
|
+ onClick={() => edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态
|
|
|
+ />
|
|
|
+ {/* 折叠状态提示徽章 */}
|
|
|
+ {edge.collapsed && (
|
|
|
+ <g
|
|
|
+ transform={`translate(${(edge.source.x + edge.target.x) / 2},${
|
|
|
+ (edge.source.y + edge.target.y) / 2
|
|
|
+ })`}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ toggleCollapse(edge.id);
|
|
|
+ }}
|
|
|
+ style={{ cursor: "pointer" }}
|
|
|
>
|
|
|
- {edge.children ? edge.children.length : "+"}
|
|
|
- </text>
|
|
|
- </g>
|
|
|
- )}
|
|
|
- </g>
|
|
|
- );
|
|
|
- })}
|
|
|
- </g>
|
|
|
-
|
|
|
- {/* 绘制节点 */}
|
|
|
- <g className={styles.nodes}>
|
|
|
- {visibleData.nodes.map((node) => {
|
|
|
- const isGoal = node.type === "goal";
|
|
|
- const data = node.data as Goal;
|
|
|
- const text = isGoal ? data.description : (node.data as Message).description || "";
|
|
|
-
|
|
|
- let textColor = "#2196F3"; // 默认蓝色
|
|
|
- if (node.type === "message") {
|
|
|
- textColor = "#9E9E9E"; // 消息节点灰色
|
|
|
- } else if (node.type === "goal" && node.level === 0) {
|
|
|
- textColor = "#4CAF50"; // 主节点绿色
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <g
|
|
|
- key={node.id}
|
|
|
- transform={`translate(${node.x},${node.y})`}
|
|
|
- onClick={() => handleNodeClick(node)}
|
|
|
- style={{ cursor: "pointer" }}
|
|
|
- >
|
|
|
- {/* 节点矩形 */}
|
|
|
- <rect
|
|
|
- x={-70}
|
|
|
- y={-25}
|
|
|
- width={150}
|
|
|
- height={50}
|
|
|
- rx={8}
|
|
|
- fill={isGoal ? "#E3F2FD" : "#F5F5F5"} // 目标节点浅蓝色,消息节点灰色
|
|
|
- stroke={selectedNodeId === node.id ? "#2196F3" : "#BDBDBD"} // 选中节点蓝色边框
|
|
|
- strokeWidth={selectedNodeId === node.id ? 2 : 1}
|
|
|
- />
|
|
|
- {/* 节点文本(带 Tooltip) */}
|
|
|
- <Tooltip content={text}>
|
|
|
- <foreignObject
|
|
|
+ <circle
|
|
|
+ r={10}
|
|
|
+ fill="#FFFFFF"
|
|
|
+ stroke={color}
|
|
|
+ strokeWidth={1}
|
|
|
+ />
|
|
|
+ <text
|
|
|
+ x={0}
|
|
|
+ y={4}
|
|
|
+ fontSize={10}
|
|
|
+ fill={color}
|
|
|
+ textAnchor="middle"
|
|
|
+ fontWeight="bold"
|
|
|
+ >
|
|
|
+ {edge.children ? edge.children.length : "+"}
|
|
|
+ </text>
|
|
|
+ </g>
|
|
|
+ )}
|
|
|
+ </g>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </g>
|
|
|
+
|
|
|
+ {/* 绘制节点 */}
|
|
|
+ <g className={styles.nodes}>
|
|
|
+ {visibleData.nodes.map((node) => {
|
|
|
+ const isGoal = node.type === "goal";
|
|
|
+ const data = node.data as Goal;
|
|
|
+ const text = isGoal ? data.description : (node.data as Message).description || "";
|
|
|
+
|
|
|
+ let textColor = "#2196F3"; // 默认蓝色
|
|
|
+ if (node.type === "message") {
|
|
|
+ textColor = "#9E9E9E"; // 消息节点灰色
|
|
|
+ } else if (node.type === "goal" && node.level === 0) {
|
|
|
+ textColor = "#4CAF50"; // 主节点绿色
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <g
|
|
|
+ key={node.id}
|
|
|
+ transform={`translate(${node.x},${node.y})`}
|
|
|
+ onClick={() => handleNodeClick(node)}
|
|
|
+ style={{ cursor: "pointer" }}
|
|
|
+ >
|
|
|
+ {/* 节点矩形 */}
|
|
|
+ <rect
|
|
|
x={-70}
|
|
|
y={-25}
|
|
|
width={150}
|
|
|
height={50}
|
|
|
- >
|
|
|
- <div
|
|
|
- className="w-full h-full flex items-center justify-center text-xs text-center leading-[1.2] px-1 box-border line-clamp-3"
|
|
|
- style={{
|
|
|
- color: textColor,
|
|
|
- WebkitBoxPack: "center", // 垂直居中
|
|
|
- }}
|
|
|
+ rx={8}
|
|
|
+ fill={isGoal ? "#E3F2FD" : "#F5F5F5"} // 目标节点浅蓝色,消息节点灰色
|
|
|
+ stroke={selectedNodeId === node.id ? "#2196F3" : "#BDBDBD"} // 选中节点蓝色边框
|
|
|
+ strokeWidth={selectedNodeId === node.id ? 2 : 1}
|
|
|
+ />
|
|
|
+ {/* 节点文本(带 Tooltip) */}
|
|
|
+ <Tooltip content={text}>
|
|
|
+ <foreignObject
|
|
|
+ x={-70}
|
|
|
+ y={-25}
|
|
|
+ width={150}
|
|
|
+ height={50}
|
|
|
>
|
|
|
- {text}
|
|
|
- </div>
|
|
|
- </foreignObject>
|
|
|
- </Tooltip>
|
|
|
- </g>
|
|
|
- );
|
|
|
- })}
|
|
|
+ <div
|
|
|
+ className="w-full h-full flex items-center justify-center text-xs text-center leading-[1.2] px-1 box-border line-clamp-3"
|
|
|
+ style={{
|
|
|
+ color: textColor,
|
|
|
+ WebkitBoxPack: "center", // 垂直居中
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {text}
|
|
|
+ </div>
|
|
|
+ </foreignObject>
|
|
|
+ </Tooltip>
|
|
|
+ </g>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </g>
|
|
|
</g>
|
|
|
- </g>
|
|
|
- </svg>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
|
|
|
{/* 控制按钮 */}
|
|
|
<div className={styles.controls}>
|
|
|
@@ -960,3 +995,5 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
+
|
|
|
+export const FlowChart = forwardRef(FlowChartComponent);
|