Browse Source

refactor: arrows in info graph

Talegorithm 15 hours ago
parent
commit
c5bbeac147
1 changed files with 89 additions and 168 deletions
  1. 89 168
      knowhub/frontend/src/pages/Dashboard.tsx

+ 89 - 168
knowhub/frontend/src/pages/Dashboard.tsx

@@ -84,17 +84,18 @@ function CoverageFlowBoard({ data }: { data: any }) {
     return { list: res, totalWidth: curX - GAP_X + 40 };
   }, [data.nodes, selectedDetailNode]);
 
-  const DETAILED_FLOWS = [
-    { source: 'req', target: 'node', label: '得到需求后,关联到实质等节点', triggerNodes: ['node', 'req'] },
-    { source: 'tool', target: 'cap', label: '新工具关联', triggerNodes: ['tool', 'cap'] },
-    { source: 'tool', target: 'proc', label: '新工具关联', triggerNodes: ['tool', 'proc'] },
-    { source: 'cap', target: 'req', label: '参考已有能力', triggerNodes: ['cap', 'req'] },
-    { source: 'node', target: 'req', label: '制作需求归纳', triggerNodes: ['node', 'req'] },
-    { source: 'req', target: 'proc', label: '搜索工序', triggerNodes: ['req', 'proc'] },
-    { source: 'req', target: 'cap', label: '总结工序时,复用、更新、创建能力', triggerNodes: ['req', 'cap'] },
-    { source: 'cap', target: 'proc', label: '参考已有能力', triggerNodes: ['cap', 'proc'] },
-    { source: 'proc', target: 'tool', label: '记录新工具需求', triggerNodes: ['proc', 'tool'] },
-    { source: 'cap', target: 'tool', label: '记录新工具需求', triggerNodes: ['cap', 'tool'] },
+  const DETAILED_FLOWS: Array<{ source: string, target: string, label: string, type: 'source' | 'association' }> = [
+    { source: 'req', target: 'node', label: '得到需求后,关联到实质等节点 [已实现待执行]', type: 'association' },
+    { source: 'tool', target: 'cap', label: '新工具关联 [已实现待执行]', type: 'association' },
+    { source: 'tool', target: 'proc', label: '新工具关联 [已实现待执行]', type: 'association' },
+    { source: 'tool', target: 'cap', label: '以工具为线索、调研归纳 [已完成]', type: 'source' },
+    { source: 'cap', target: 'req', label: '参考已有能力', type: 'source' },
+    { source: 'node', target: 'req', label: '制作需求归纳 [已完成]', type: 'source' },
+    { source: 'req', target: 'proc', label: '逐个需求调研、总结工序 [5%测试]', type: 'source' },
+    { source: 'req', target: 'cap', label: '总结工序时,复用、更新、创建能力 [5%测试]', type: 'source' },
+    { source: 'cap', target: 'proc', label: '参考已有能力', type: 'source' },
+    { source: 'proc', target: 'tool', label: '记录新工具需求', type: 'source' },
+    { source: 'cap', target: 'tool', label: '记录新工具需求', type: 'source' },
   ];
   // --- 补充辅助流最终汇合后的状态文本配置 ---
   const TARGET_STATUS_MAP: Record<string, string> = {
@@ -104,21 +105,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
     tool: '完成',
   };
 
-  const topOnlyFlows = DETAILED_FLOWS.filter((flow) => {
-    const sIdx = data.nodes.findIndex((n: any) => n.key === flow.source);
-    const tIdx = data.nodes.findIndex((n: any) => n.key === flow.target);
-    const isForwardAdjacent = tIdx === sIdx + 1;
-    return sIdx >= 0 && tIdx >= 0 && !isForwardAdjacent;
-  });
-  const getAdjacentDetailFlow = (leftKey?: string, rightKey?: string) => {
-    if (!leftKey || !rightKey || !selectedDetailNode) return null;
-    const pairFlows = DETAILED_FLOWS.filter((flow) => {
-      const isForwardAdjacent = flow.source === leftKey && flow.target === rightKey;
-      return isForwardAdjacent && flow.target === selectedDetailNode;
-    });
-    if (pairFlows.length === 0) return null;
-    return pairFlows[0];
-  };
+  const topOnlyFlows = DETAILED_FLOWS;
 
   // --- 1. 整理常驻的单步覆盖率边数据 ---
   const baseEdges = data.edges || [];
@@ -288,9 +275,9 @@ function CoverageFlowBoard({ data }: { data: any }) {
           </defs>
 
           {(() => {
-            // Compute active top flows and sort them globally to assign non-overlapping lanes
+            // 仅对当前选中卡片的“入边”(即 target 为该节点)进行计算和渲染
             const activeTopFlows = selectedDetailNode ? topOnlyFlows.filter((flow) =>
-              flow.target === selectedDetailNode || flow.source === selectedDetailNode
+              flow.target === selectedDetailNode
             ) : [];
 
             const sortedActiveFlows = [...activeTopFlows].sort((a, b) => {
@@ -301,109 +288,80 @@ function CoverageFlowBoard({ data }: { data: any }) {
               return Math.abs(tIdxA - sIdxA) - Math.abs(tIdxB - sIdxB);
             });
             const flowLaneMap = new Map();
-            sortedActiveFlows.forEach((flow, idx) => flowLaneMap.set(`${flow.source}-${flow.target}`, idx));
+            sortedActiveFlows.forEach((flow, idx) => flowLaneMap.set(`${flow.source}-${flow.target}-${flow.label}`, idx));
 
-            return data.nodes.map((node: any, tIdx: number) => {
-              const isSelectedTarget = node.key === selectedDetailNode;
-              const targetFlows = activeTopFlows.filter((flow) => flow.target === node.key);
-              if (targetFlows.length === 0) return null;
+            return sortedActiveFlows.map((flow, idx) => {
+              const sIdx = data.nodes.findIndex((n: any) => n.key === flow.source);
+              const tIdx = data.nodes.findIndex((n: any) => n.key === flow.target);
+              if (sIdx < 0 || tIdx < 0) return null;
 
-              const tX = dynamicLayouts.list[tIdx]?.x || 0;
-              const prevXWithWidth = tIdx > 0 ? (dynamicLayouts.list[tIdx - 1]?.x || 0) + NODE_W : tX - 100;
-              const dropX = (tX + prevXWithWidth) / 2;
-              const entryY = FLOW_Y;
+              const laneIdx = flowLaneMap.get(`${flow.source}-${flow.target}-${flow.label}`) ?? 0;
+              const isAdjacentForward = tIdx === sIdx + 1;
+
+              const sX = dynamicLayouts.list[sIdx]?.cx || 0;
+              const tX = dynamicLayouts.list[tIdx]?.cx || 0;
+              const dir = Math.sign(tX - sX);
               const R = 12;
 
+              let pathD = '';
+              let labelY = 0;
+              let labelX = (sX + tX) / 2;
+
+              if (isAdjacentForward) {
+                // 紧挨着向右的直接用直线
+                const sRight = sX + NODE_W / 2;
+                const tLeft = tX - NODE_W / 2;
+                const y = CARD_TOP + TOP_H / 2;
+                pathD = `M ${sRight} ${y} L ${tLeft} ${y}`;
+                labelX = (sRight + tLeft) / 2;
+                labelY = y - 8;
+              } else {
+                // 其他任何跨步或后退的边,统统在从上方空域走!
+                const startY = CARD_TOP - 2;
+                const flowTrunkY = CARD_TOP - 26 - laneIdx * 18;
+                pathD = [
+                  `M ${sX} ${startY}`,
+                  `L ${sX} ${flowTrunkY + R}`,
+                  `Q ${sX} ${flowTrunkY}, ${sX + dir * R} ${flowTrunkY}`,
+                  `L ${tX - dir * R} ${flowTrunkY}`,
+                  `Q ${tX} ${flowTrunkY}, ${tX} ${flowTrunkY + R}`,
+                  `L ${tX} ${startY}`
+                ].join(' ');
+                labelY = flowTrunkY - 8;
+              }
+
+              const pathColor = getNodeColor(flow.source);
+
               return (
-                <g key={`top-group-${node.key}`}>
-                  {targetFlows.map((flow, idx) => {
-                    const sIdx = data.nodes.findIndex((n: any) => n.key === flow.source);
-                    if (sIdx < 0) return null;
-
-                    const laneIdx = flowLaneMap.get(`${flow.source}-${flow.target}`) ?? 0;
-                    const flowTrunkY = CARD_TOP - 26 - laneIdx * 18;
-
-                  const sX = dynamicLayouts.list[sIdx]?.cx || 0;
-                  const startY = CARD_TOP - 2;
-                  const dir = Math.sign(dropX - sX); // -1 if sX > dropX
-
-                  // Rounded polyline path computation
-                  const pathD = [
-                    `M ${sX} ${startY}`,
-                    `L ${sX} ${flowTrunkY + R}`,
-                    `Q ${sX} ${flowTrunkY}, ${sX + dir * R} ${flowTrunkY}`,
-                    `L ${dropX - dir * R} ${flowTrunkY}`,
-                    `Q ${dropX} ${flowTrunkY}, ${dropX} ${flowTrunkY + R}`,
-                    `L ${dropX} ${entryY - R}`,
-                    `Q ${dropX} ${entryY}, ${dropX + R} ${entryY}`,
-                    `L ${tX - 4} ${entryY}`
-                  ].join(' ');
-
-                  const pathColor = getNodeColor(flow.source);
-                  const labelX = (sX + dropX) / 2;
-                  const statusTextForEdge = TARGET_STATUS_MAP[flow.target] || '';
-                  const isSolidLine = statusTextForEdge.includes('完成');
-
-                  return (
-                    <g key={`top-edge-${idx}`} className="transition-opacity duration-500 opacity-100">
-                      <path
-                        d={pathD}
-                        fill="none"
-                        stroke={pathColor}
-                        strokeWidth="1.5"
-                        strokeLinecap="round"
-                        strokeLinejoin="round"
-                        markerEnd={`url(#arrow-top-${idx})`}
-                      />
-
-                      {/* 汇合前写入当前线条内容(放在横线处,纯文字无背景盒) */}
-                      <g className={cn("transition-opacity duration-300", !isSelectedTarget ? "opacity-0" : "opacity-100")} transform={`translate(${labelX}, ${flowTrunkY})`}>
-                        <text
-                          x={0}
-                          y={-6}
-                          fill={pathColor}
-                          fontSize="9"
-                          fontWeight="700"
-                          opacity="0.85"
-                          textAnchor="middle"
-                          letterSpacing="0.02em"
-                        >
-                          {flow.label}
-                        </text>
-                      </g>
-                    </g>
-                  );
-                })}
-
-                {/* 汇合后写状态 (完成,直接压在横线上) - 仅当有流向该卡片时渲染,如果是多条则共用。由于总览常驻时也要显示,直接绘制 */}
-                <g className={cn("transition-opacity duration-500 delay-150", !isSelectedTarget ? "opacity-0" : "opacity-100")} transform={`translate(${dropX + 46}, ${entryY})`}>
-                  <rect
-                    x={-20}
-                    y={-9}
-                    rx={6}
-                    ry={6}
-                    width={40}
-                    height={18}
-                    fill="white"
-                    opacity="1"
-                    stroke={getNodeColor(node.key)}
-                    strokeOpacity="0.22"
+                <g key={`flow-${idx}`} className="transition-opacity duration-500 opacity-100">
+                  <path
+                    d={pathD}
+                    fill="none"
+                    stroke={pathColor}
+                    strokeWidth="1.5"
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeDasharray={flow.type === 'association' ? "4 4" : undefined}
+                    markerEnd={`url(#arrow-top-${idx})`}
                   />
-                  <text
-                    x={0}
-                    y={3.5}
-                    fill={getNodeColor(node.key)}
-                    fontSize="9"
-                    fontWeight="800"
-                    textAnchor="middle"
-                    letterSpacing="0.02em"
-                  >
-                    {TARGET_STATUS_MAP[node.key] || '完成'}
-                  </text>
+                  <g className="transition-opacity duration-300 opacity-100" transform={`translate(${labelX}, ${labelY})`}>
+                    <text
+                      x={0}
+                      y={0}
+                      fill={pathColor}
+                      fontSize="9"
+                      fontWeight="700"
+                      opacity="0.85"
+                      textAnchor="middle"
+                      letterSpacing="0.02em"
+                    >
+                      {flow.label}
+                    </text>
+                  </g>
                 </g>
-              </g>
-            );
-          })})()}
+              );
+            });
+          })()}
         </svg>
 
         {/* ================= 2. 卡片与主流程层 ================= */}
@@ -411,13 +369,6 @@ function CoverageFlowBoard({ data }: { data: any }) {
           const style = nodeStyle[node.key] || nodeStyle.node;
           const edge = baseEdges[i];
           const x = dynamicLayouts.list[i]?.x || 0;
-          const nextX = dynamicLayouts.list[i + 1]?.x || (x + NODE_W + GAP_X);
-          const gapWidth = Math.max(0, nextX - (x + NODE_W));
-          const adjacentDetailFlow = getAdjacentDetailFlow(node.key, data.nodes[i + 1]?.key);
-          const mainFlowColor = adjacentDetailFlow ? getNodeColor(adjacentDetailFlow.source) : getNodeColor(node.key);
-
-          const targetKeyForEdge = data.nodes[i + 1]?.key;
-          const statusTextForEdge = targetKeyForEdge ? (TARGET_STATUS_MAP[targetKeyForEdge] || '') : '';
           const isCardCompleted = (TARGET_STATUS_MAP[node.key] || '').includes('完成');
 
           return (
@@ -447,46 +398,16 @@ function CoverageFlowBoard({ data }: { data: any }) {
                   </div>
                 </div>
               </div>
-
-              {/* === B. 上方主流程箭头 === */}
-              {edge && i < data.nodes.length - 1 && edge.flowLabel !== '提取能力' && (
-                <div
-                  className={cn(
-                    "absolute flex flex-col items-center justify-center z-10 transition-all duration-500",
-                    selectedDetailNode ? "opacity-85" : "opacity-100"
-                  )}
-                  style={{ left: x + NODE_W, top: FLOW_Y - 10, width: gapWidth, height: 20 }}
-                >
-                  <div
-                    className={cn(
-                      "text-[10px] font-black tracking-widest absolute -top-5 whitespace-nowrap uppercase transition-opacity duration-300",
-                      (data.nodes[i + 1]?.key === selectedDetailNode || data.nodes[i]?.key === selectedDetailNode) ? "opacity-100" : "opacity-0"
-                    )}
-                    style={{ color: mainFlowColor }}
-                  >
-                    {edge.flowLabel}
-                  </div>
-                  <div className="w-full flex items-center px-1">
-                    <div
-                      className="flex-1 transition-all duration-300 h-[2px] rounded-full"
-                      style={{
-                        backgroundColor: mainFlowColor,
-                        borderColor: 'transparent',
-                        opacity: !selectedDetailNode ? 0 : ((data.nodes[i + 1]?.key === selectedDetailNode || data.nodes[i]?.key === selectedDetailNode) ? 0.95 : 0.05),
-                      }}
-                    />
-                    <svg className="w-2.5 h-3.5 -ml-[1px] transition-opacity duration-300" 
-                      style={{ color: mainFlowColor, opacity: !selectedDetailNode ? 0 : ((data.nodes[i + 1]?.key === selectedDetailNode || data.nodes[i]?.key === selectedDetailNode) ? 0.95 : 0.05) }} 
-                      viewBox="0 0 8 12" fill="currentColor">
-                      <path d="M0 0L8 6L0 12V0Z" />
-                    </svg>
-                  </div>
-                </div>
-              )}
             </div>
           );
         })}
 
+        {/* ================= 图例 ================= */}
+        <div className="absolute left-6 bottom-4 flex gap-4 text-[11px] text-slate-500/80 font-bold z-50">
+          <div className="flex items-center gap-1.5"><div className="w-5 h-0 border-t-2 border-slate-400"></div>数据来源</div>
+          <div className="flex items-center gap-1.5"><div className="w-5 h-0 border-t-2 border-dashed border-slate-400"></div>数据关联</div>
+        </div>
+
         {/* ================= 4. 底部覆盖数字与百分比徽章 ================= */}
         {activeEdges.map((edge: any) => {
           const isConnectedToSelectedNode =
@@ -1693,7 +1614,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
         });
         const finalTreeData = { ...data };
         setTreeData(finalTreeData);
-        
+
         // 存入全局缓存
         globalCacheTreeData = finalTreeData;
         globalCacheDbData = { reqs, caps, tools, know, procs };