|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
import type { FC } from "react";
|
|
|
import * as d3 from "d3";
|
|
|
import type { Goal } from "../../types/goal";
|
|
|
@@ -14,6 +14,7 @@ interface FlowChartProps {
|
|
|
goals: Goal[];
|
|
|
msgGroups?: Record<string, Message[]>;
|
|
|
onNodeClick?: (node: Goal, edge?: EdgeType) => void;
|
|
|
+ onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void;
|
|
|
}
|
|
|
|
|
|
interface LayoutNode extends d3.HierarchyPointNode<Goal> {
|
|
|
@@ -23,8 +24,9 @@ interface LayoutNode extends d3.HierarchyPointNode<Goal> {
|
|
|
|
|
|
type TreeGoal = Goal & { children?: TreeGoal[] };
|
|
|
const VIRTUAL_ROOT_ID = "__VIRTUAL_ROOT__";
|
|
|
+export type SubTraceEntry = { id: string; mission?: string };
|
|
|
|
|
|
-export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick }) => {
|
|
|
+export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onSubTraceClick }) => {
|
|
|
// 确保 goals 中包含 END 节点
|
|
|
const displayGoals = useMemo(() => {
|
|
|
if (!goals) return [];
|
|
|
@@ -176,21 +178,96 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
return [...nonVirtualLinks, ...links];
|
|
|
}, [layoutData, displayGoals]);
|
|
|
|
|
|
- // 节点点击:记录选中状态并回传对应边
|
|
|
- const handleNodeClick = (node: LayoutNode) => {
|
|
|
- setSelectedNodeId(node.data.id);
|
|
|
- const nearestLink = mainLinks.find((link) => link.target.data.id === node.data.id);
|
|
|
- const edge: EdgeType | undefined = nearestLink
|
|
|
- ? {
|
|
|
- id: `${nearestLink.source.data.id}-${nearestLink.target.data.id}`,
|
|
|
- source: nearestLink.source.data.id,
|
|
|
- target: nearestLink.target.data.id,
|
|
|
- label: "",
|
|
|
+ const normalizeSubTraceEntries = (goal: Goal): SubTraceEntry[] => {
|
|
|
+ const raw = goal.sub_trace_ids || [];
|
|
|
+ return raw
|
|
|
+ .map((item) => {
|
|
|
+ if (typeof item === "string") return { id: item };
|
|
|
+ if (item && typeof item === "object" && "trace_id" in item) {
|
|
|
+ const meta = item as { trace_id?: unknown; mission?: unknown };
|
|
|
+ const id = typeof meta.trace_id === "string" ? meta.trace_id : "";
|
|
|
+ const mission = typeof meta.mission === "string" ? meta.mission : undefined;
|
|
|
+ return id ? { id, mission } : null;
|
|
|
}
|
|
|
- : undefined;
|
|
|
- onNodeClick?.(node.data, edge);
|
|
|
+ return null;
|
|
|
+ })
|
|
|
+ .filter((entry): entry is SubTraceEntry => !!entry && entry.id.length > 0);
|
|
|
};
|
|
|
|
|
|
+ // 节点点击:记录选中状态并回传对应边
|
|
|
+ const handleNodeClick = useCallback(
|
|
|
+ (node: LayoutNode) => {
|
|
|
+ setSelectedNodeId(node.data.id);
|
|
|
+ const nearestLink = mainLinks.find((link) => link.target.data.id === node.data.id);
|
|
|
+ const edge: EdgeType | undefined = nearestLink
|
|
|
+ ? {
|
|
|
+ id: `${nearestLink.source.data.id}-${nearestLink.target.data.id}`,
|
|
|
+ source: nearestLink.source.data.id,
|
|
|
+ target: nearestLink.target.data.id,
|
|
|
+ label: "",
|
|
|
+ }
|
|
|
+ : undefined;
|
|
|
+ onNodeClick?.(node.data, edge);
|
|
|
+ },
|
|
|
+ [mainLinks, onNodeClick],
|
|
|
+ );
|
|
|
+
|
|
|
+ const subTraceLinks = useMemo(() => {
|
|
|
+ if (!layoutData)
|
|
|
+ return [] as Array<{
|
|
|
+ link: d3.HierarchyPointLink<Goal>;
|
|
|
+ label?: string;
|
|
|
+ key: string;
|
|
|
+ onClick: () => void;
|
|
|
+ curveOffset?: number;
|
|
|
+ }>;
|
|
|
+ const nodeMap = new Map(layoutData.nodes.map((node) => [node.data.id, node]));
|
|
|
+ const orderedIds = displayGoals.map((goal) => goal.id);
|
|
|
+ const nextIdMap = new Map<string, string>();
|
|
|
+ for (let i = 0; i < orderedIds.length - 1; i += 1) {
|
|
|
+ nextIdMap.set(orderedIds[i], orderedIds[i + 1]);
|
|
|
+ }
|
|
|
+
|
|
|
+ const links: Array<{
|
|
|
+ link: d3.HierarchyPointLink<Goal>;
|
|
|
+ label?: string;
|
|
|
+ key: string;
|
|
|
+ onClick: () => void;
|
|
|
+ curveOffset?: number;
|
|
|
+ }> = [];
|
|
|
+
|
|
|
+ displayGoals.forEach((goal) => {
|
|
|
+ const entries = normalizeSubTraceEntries(goal);
|
|
|
+ if (entries.length === 0) return;
|
|
|
+ const sourceBase = nodeMap.get(goal.id);
|
|
|
+ const nextId = nextIdMap.get(goal.id);
|
|
|
+ const targetBase = nextId ? nodeMap.get(nextId) : undefined;
|
|
|
+ if (!sourceBase || !targetBase) return;
|
|
|
+
|
|
|
+ const centerIndex = (entries.length - 1) / 2;
|
|
|
+ entries.forEach((entry, index) => {
|
|
|
+ const offset = (index - centerIndex) * 60;
|
|
|
+ const onClick = () => {
|
|
|
+ if (onSubTraceClick) {
|
|
|
+ onSubTraceClick(goal, entry);
|
|
|
+ } else {
|
|
|
+ handleNodeClick(sourceBase);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ links.push({
|
|
|
+ link: { source: sourceBase, target: targetBase },
|
|
|
+ label: entry.mission,
|
|
|
+ key: `${goal.id}-${entry.id}-explore-${index}`,
|
|
|
+ onClick,
|
|
|
+ curveOffset: offset,
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ return links;
|
|
|
+ }, [displayGoals, handleNodeClick, layoutData, onSubTraceClick]);
|
|
|
+
|
|
|
// 当前选中节点的消息链
|
|
|
const selectedMessages = useMemo(() => {
|
|
|
if (!selectedNodeId) return [];
|
|
|
@@ -345,6 +422,22 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
);
|
|
|
})}
|
|
|
</g>
|
|
|
+ {subTraceLinks.length > 0 && (
|
|
|
+ <g className={styles.links}>
|
|
|
+ {subTraceLinks.map((item) => (
|
|
|
+ <Edge
|
|
|
+ key={`subtrace-line-${item.key}`}
|
|
|
+ link={item.link}
|
|
|
+ label={item.label}
|
|
|
+ highlighted={false}
|
|
|
+ dimmed={false}
|
|
|
+ onClick={item.onClick}
|
|
|
+ mode="line"
|
|
|
+ curveOffset={item.curveOffset}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </g>
|
|
|
+ )}
|
|
|
<g className={styles.nodes}>
|
|
|
{layoutData.nodes
|
|
|
.filter((node) => node.data.id !== VIRTUAL_ROOT_ID)
|
|
|
@@ -387,6 +480,22 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
|
|
|
);
|
|
|
})}
|
|
|
</g>
|
|
|
+ {subTraceLinks.length > 0 && (
|
|
|
+ <g className={styles.links}>
|
|
|
+ {subTraceLinks.map((item) => (
|
|
|
+ <Edge
|
|
|
+ key={`subtrace-label-${item.key}`}
|
|
|
+ link={item.link}
|
|
|
+ label={item.label}
|
|
|
+ highlighted={false}
|
|
|
+ dimmed={false}
|
|
|
+ onClick={item.onClick}
|
|
|
+ mode="label"
|
|
|
+ curveOffset={item.curveOffset}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </g>
|
|
|
+ )}
|
|
|
{messageOverlay && (
|
|
|
<g>
|
|
|
{messageOverlay.paths.map((p, idx) => (
|