react19设计AntVX6 人工智能建模 DAG 图

发布于:2025-02-21 ⋅ 阅读:(162) ⋅ 点赞:(0)

HomeTop.tsx

import React, { useState, useEffect, useRef } from 'react'
import useStore from '../../../store/state'
import { Graph, Path } from '@antv/x6'
import { History } from '@antv/x6-plugin-history'
import AlgoNode from '../../AntVX6/AlgoNode'
import { register } from '@antv/x6-react-shape'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import styles from './HomeTop.module.scss'
import { Space, Button } from 'antd'
import { IoExpandOutline } from 'react-icons/io5'
import { CiSaveDown1 } from 'react-icons/ci'
import { LuFileTerminal } from 'react-icons/lu'
import { SiStreamrunners } from 'react-icons/si'
import { TbArrowBackUp, TbArrowForwardUp } from 'react-icons/tb'
import { FaSearchPlus, FaSearchMinus } from 'react-icons/fa'
import { AiOutlineFullscreenExit } from 'react-icons/ai'
register({
  shape: 'dag-node',
  width: 180,
  height: 36,
  component: AlgoNode,
  ports: {
    groups: {
      left: {
        position: 'left',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#C2C8D5',
            strokeWidth: 1,
            fill: '#fff',
          },
        },
      },
      right: {
        position: 'right',
        attrs: {
          circle: {
            r: 4,
            magnet: true,
            stroke: '#C2C8D5',
            strokeWidth: 1,
            fill: '#fff',
          },
        },
      },
    },
  },
})
// 注册自定义边样式
Graph.registerEdge(
  'dag-edge',
  {
    inherit: 'edge',
    attrs: {
      line: {
        stroke: '#3498DB',
        strokeWidth: 3,
        targetMarker: {
          name: 'block', // 箭头类型,可以是 block、classic、circle 等
          width: 25, // 箭头宽度
          height: 15, // 箭头高度
          fill: '#3498DB', // 箭头颜色
        },
      },
    },
  },
  true
)
//注册连接器的样式
Graph.registerConnector(
  'algo-connector',
  (sourcePoint, targetPoint) => {
    const hgap = Math.abs(targetPoint.x - sourcePoint.x)
    const path = new Path()
    path.appendSegment(Path.createSegment('M', sourcePoint.x - 4, sourcePoint.y))
    path.appendSegment(Path.createSegment('L', sourcePoint.x + 12, sourcePoint.y))
    // 水平三阶贝塞尔曲线
    path.appendSegment(
      Path.createSegment(
        'C',
        sourcePoint.x < targetPoint.x ? sourcePoint.x + hgap / 2 : sourcePoint.x - hgap / 2,
        sourcePoint.y,
        sourcePoint.x < targetPoint.x ? targetPoint.x - hgap / 2 : targetPoint.x + hgap / 2,
        targetPoint.y,
        targetPoint.x - 6,
        targetPoint.y
      )
    )
    path.appendSegment(Path.createSegment('L', targetPoint.x + 2, targetPoint.y))
    return path.serialize()
  },
  true
)
const HomeTop: React.FC = () => {
  const graph = useRef<Graph | null>(null) // 使用 useRef 保存 graph 引用
  useEffect(() => {
    //为什么要放置在内部因为=> <div id = 'antVX6Container'可能还未挂载,就调用的方法,是用useEffect可以保证挂载后再调用
    graph.current = new Graph({
      container: document.getElementById('antVX6Container')!,
      autoResize: true,
      panning: true,
      mousewheel: true,
      background: {
        color: '#d9e4f5',
      },
      grid: {
        visible: true,
        type: 'doubleMesh',
        args: [
          {
            color: '#eee', // 主网格线颜色
            thickness: 1, // 主网格线宽度
          },
          {
            color: '#ddd', // 次网格线颜色
            thickness: 1, // 次网格线宽度
            factor: 4, // 主次网格线间隔
          },
        ],
      },
      //连线交互
      connecting: {
        connector: 'algo-connector',
        snap: {
          radius: 50, //自动吸附,并设置自动吸附路径
        },
        allowBlank: false, // 是否允许连接到画布空白位置的点(就是能不能拉线连空白的地方)
        allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点,就是能不能自我连线(箭头不能穿过仪器)
        allowNode: false, //是否允许边连接到节点(非节点上的连接桩),默认为 true 。(就是要让它必须连接到连接桩,连接到节点不行)
        allowEdge: false, //是否可以同一个起点终点,在箭头的线中间加一个箭头,就是一条线能一直加箭头
        allowMulti: true, // 是否可以一个起点连多个终点
        highlight: true, // 拖动边时,是否高亮显示所有可用的连接桩或节点,默认值为 false 。一般都会与 highlighting 联合使用。
        createEdge() {
          return graph.current!.createEdge({
            shape: 'dag-edge',
            attrs: {
              line: {
                strokeDasharray: '5 5',
              },
            },
            zIndex: -1,
          })
        },
      },
      //高亮器
      highlighting: {
        // 当连接桩可以被链接时,在连接桩外围渲染一个 2px 宽的红色矩形框
        magnetAvailable: {
          name: 'stroke',
          args: {
            padding: 4,
            attrs: {
              'stroke-width': 2,
              stroke: 'red',
            },
          },
        },
      },
    })
    //开启框选功能
    graph.current.use(
      new Selection({
        enabled: true, // 启用选择功能。当为 true 时,可以在画布上拖动来选择节点或边
        rubberband: true, // 启用橡皮筋选择框。当按下鼠标并拖动时,会显示一个矩形框来选择多个节点
        movable: true, // 启用拖动选中的节点。当选中节点后,可以拖动节点进行移动
        showNodeSelectionBox: false, // 显示节点的选中框。当节点被选中时,会出现一个框框显示节点被选中状态
        pointerEvents: 'none', // 禁用节点或边的指针事件,通常用于在某些情况下阻止鼠标事件,比如不希望选中框遮挡其他元素
        modifiers: 'alt', // 设置按住 `alt` 键时启用选择操作。默认是按住 `shift` 键进行多选,这里将其更改为按 `alt` 键
      })
    )
    //画布开启对齐线功能
    graph.current.use(
      new Snapline({
        enabled: true,
      })
    )
    //开启历史功能
    graph.current.use(
      new History({
        enabled: true,
      })
    )
    // 方法:改变连接桩的可见性
    const changePortsVisible = (visible: boolean) => {
      const container = document.getElementById('antVX6Container')
      if (!container) return // 确保容器存在
      const ports = container.querySelectorAll('.x6-port-body')
      const texts = container.querySelectorAll('.x6-port-label')
      for (let i = 0; i < texts.length; i++) {
        ;(texts[i] as HTMLElement).style.visibility = visible ? 'visible' : 'hidden'
      }
      for (let i = 0, len = ports.length; i < len; i++) {
        ;(ports[i] as HTMLElement).style.visibility = visible ? 'visible' : 'hidden'
      }
    }
    // 监听节点的鼠标进入事件,显示连接桩
    graph.current.on('node:mouseenter', ({ node }) => {
      changePortsVisible(true)
      node.addTools({
        name: 'button-remove',
        args: {
          x: '100%',
          y: 0,
          offset: { x: -10, y: 10 },
        },
      })
    })
    // 监听节点的鼠标离开事件,隐藏连接桩
    graph.current.on('node:mouseleave', ({ node }) => {
      changePortsVisible(false)
      node.removeTools()
    })
    // 监听节点数据变化事件
    graph.current.on('node:change:data', ({ node }) => {
      const edges = graph.current!.getIncomingEdges(node) // 获取入边
      const { status } = node.getData() as { status: string } // 获取节点状态

      edges?.forEach(edge => {
        if (status === 'running') {
          edge.attr('line/strokeDasharray', 5) // 设置虚线
          edge.attr('line/style/animation', 'running-line 30s infinite linear') // 添加动画
        } else {
          edge.attr('line/strokeDasharray', '') // 清除虚线
          edge.attr('line/style/animation', '') // 移除动画
        }
      })
    })
  }, [])

  const { algorihtm } = useStore()
  const [dragOver, setDragOver] = useState(false) // 判断是否正在拖拽
  // 拖拽区域的样式
  const style = {
    border: dragOver ? '2px dashed #000' : '2px solid transparent',
  }

  // 处理拖拽开始
  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault() // 必须阻止默认行为才能触发 drop 事件
    setDragOver(true)
  }

  // 处理拖拽结束
  const handleDragLeave = () => {
    setDragOver(false)
  }

  // 处理放置操作
  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    setDragOver(false)
    console.log('HomeTop.tsx-handleDrop:算子信息' + algorihtm)
    // 获取鼠标在画布中的坐标
    const { x, y } = graph.current!.pageToLocal(e.pageX, e.pageY)
    // 设置节点的宽高
    const nodeWidth = 180
    const nodeHeight = 36
    // 调整坐标,使节点的中心在鼠标放置的位置
    const adjustedX = x - nodeWidth / 2
    const adjustedY = y - nodeHeight / 2
    graph.current!.addNode({
      id: String(algorihtm.key),
      shape: 'dag-node',
      data: { label: algorihtm.title, status: 'default' },
      x: adjustedX,
      y: adjustedY,
      ports: {
        items: [
          {
            id: 'port_1',
            group: 'left',
          },
          {
            id: 'port_2',
            group: 'right',
          },
        ],
      },
    })
  }

  function runCode() {
    // 获取所有节点和边
    const nodes = graph.current!.getNodes() // 获取画布中所有的节点
    const edges = graph.current!.getEdges() // 获取画布中所有的连线

    // 构建图的邻接表、反向邻接表和入出度表
    const adjList: Record<string, string[]> = {} // 邻接表,用于存储每个节点指向的节点
    const reverseAdjList: Record<string, string[]> = {} // 反向邻接表,用于存储指向该节点的节点
    const indegree: Record<string, number> = {} // 入度表,记录每个节点的被连接次数
    const outdegree: Record<string, number> = {} // 出度表,记录每个节点指向其他节点的次数

    // 初始化邻接表和度表
    nodes.forEach(node => {
      const nodeId = node.id // 节点的唯一标识符
      adjList[nodeId] = [] // 初始化为空数组,表示暂时没有指向任何节点
      reverseAdjList[nodeId] = [] // 初始化为空数组,表示暂时没有被其他节点指向
      indegree[nodeId] = 0 // 初始入度为 0
      outdegree[nodeId] = 0 // 初始出度为 0
    })

    // 填充邻接表和度表
    edges.forEach(edge => {
      const source = (edge.getSource() as { cell: string }).cell // 获取边的起点节点 ID
      const target = (edge.getTarget() as { cell: string }).cell // 获取边的终点节点 ID
      if (adjList[source] && adjList[target]) {
        // 确保 source 和 target 都在节点列表中
        adjList[source].push(target) // 起点的邻接表增加终点
        reverseAdjList[target].push(source) // 终点的反向邻接表增加起点
        indegree[target]++ // 终点的入度加 1
        outdegree[source]++ // 起点的出度加 1
      }
    })

    // 拓扑排序逻辑
    const queue: string[] = [] // 队列,用于存储入度为 0 的节点
    for (const nodeId in indegree) {
      if (indegree[nodeId] === 0) {
        // 找出所有入度为 0 的节点
        queue.push(nodeId) // 加入队列
      }
    }

    const topoOrder: string[] = [] // 用于存储拓扑排序的结果
    while (queue.length > 0) {
      const nodeId = queue.shift()! // 从队列中取出一个节点
      topoOrder.push(nodeId) // 将节点加入拓扑排序结果
      adjList[nodeId].forEach(neighbor => {
        // 遍历该节点的所有邻居节点
        indegree[neighbor]-- // 邻居节点的入度减 1
        if (indegree[neighbor] === 0) {
          // 如果邻居节点的入度变为 0
          queue.push(neighbor) // 加入队列
        }
      })
    }

    // 检查是否有环
    if (topoOrder.length !== nodes.length) {
      // 如果拓扑排序结果的节点数与总节点数不一致,说明有环\
      console.log('错误连接,出现环,请查看连接情况并修正!')
      return // 中断函数
    }

    // 检查未连接节点
    const allNodes = new Set(nodes.map(node => node.id)) // 获取所有节点的 ID 集合
    const reachableFromStart = new Set<string>() // 用于存储从起点可达的节点
    const reachableFromEnd = new Set<string>() // 用于存储从终点反向可达的节点

    // 深度优先搜索(DFS)函数
    const dfs = (start: string, visited: Set<string>, graph: Record<string, string[]>) => {
      if (visited.has(start)) return // 如果节点已经访问过,直接返回
      visited.add(start) // 标记当前节点为已访问
      graph[start].forEach(neighbor => dfs(neighbor, visited, graph)) // 遍历当前节点的所有邻居
    }

    // 从所有起点出发,检查哪些节点可达
    topoOrder.forEach(node => {
      dfs(node, reachableFromStart, adjList) // 正向 DFS 检查从起点可达的节点
      dfs(node, reachableFromEnd, reverseAdjList) // 反向 DFS 检查从终点反向可达的节点
    })

    // 找到未连接的节点
    const unconnectedNodes = Array.from(allNodes).filter(
      node => !reachableFromStart.has(node) && !reachableFromEnd.has(node)
    )

    if (unconnectedNodes.length > 0) {
      // 如果有未连接的节点
      console.log('运行错误,存在未连接的算子: ${unconnectedNodes.join(', ')}`')
      return // 中断函数
    }

    // 导出拓扑排序结果
    console.log('Topological Order:', topoOrder)

    // 导出连接关系并按拓扑顺序输出
    const orderedConnections: Record<string, string[]> = {}
    topoOrder.forEach(nodeId => {
      const outgoingNodes = adjList[nodeId]
      if (outgoingNodes.length > 0) {
        orderedConnections[nodeId] = outgoingNodes
      }
    })

    // 导出节点数据
    const nodesData: Record<
      string,
      { label: string; status: string; position: { x: number; y: number } }
    > = {}
    nodes.forEach(node => {
      const data = node.getData() // 获取节点的数据
      nodesData[node.id] = {
        label: data.label, // 节点的标签
        status: data.status, // 节点的状态
        position: node.getPosition(), // 节点的位置
      }
    })

    // 输出最终结果
    const result = {
      topoOrder, // 拓扑排序结果
      orderedConnections, // 按拓扑顺序排列的连接关系
      nodesData, // 节点数据
    }
    console.log('Result:', JSON.stringify(result, null, 2)) // 打印结果

    // 动态运行拓扑
    simulateExecution(topoOrder)
  }
  // 延时函数
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
  async function simulateExecution(topoOrder: string[]) {
    for (let i = 0; i < topoOrder.length; i++) {
      const nodeId = topoOrder[i]
      const node = graph.current!.getCellById(nodeId)

      // 将当前节点设置为运行中状态
      node.setData({
        ...node.getData(),
        status: 'running',
      })
      await delay(2000) // 模拟运行时间
      // 根据模拟逻辑设置状态
      const isSuccess = Math.random() > 0.1 // 80% 成功概率
      node.setData({
        ...node.getData(),
        status: isSuccess ? 'success' : 'failed',
      })

      // 如果失败,终止后续执行
      if (!isSuccess) {
        console.log(`运行失败,节点 ${nodeId} 执行错误!`)
        break
      }
    }
    console.log(`运行完成!`)
  }
  return (
    <div style={{ height: '100%', width: '100%' }}>
      <div className={styles['topBar']}>
        <Space style={{ marginLeft: '20px' }}>
          <div
            className={styles['controlItem']}
            onClick={() => graph.current!.zoomToFit({ maxScale: 2 })}
          >
            <i>
              <IoExpandOutline />
            </i>
            <span>自适应放大</span>
          </div>

          <div
            className={styles['controlItem']}
            onClick={() => {
              graph.current!.toJSON()
              console.log(graph.current!.toJSON())
            }}
          >
            <i>
              <LuFileTerminal />
            </i>
            <span>保存</span>
          </div>
          <div
            className={styles['controlItem']}
            onClick={() => {
              graph.current!.toJSON()
              console.log(graph.current!.toJSON({}))
            }}
          >
            <i>
              <CiSaveDown1 />
            </i>
            <span>导出分析流</span>
          </div>
        </Space>
      </div>
      <div className={styles['topButton']}>
        <Space size="large">
          <Button
            icon={<SiStreamrunners />}
            className={styles['topButtonRun']}
            onClick={runCode}
          ></Button>
          <Space>
            <Button
              icon={<TbArrowBackUp />}
              className={styles['topButtonCancel']}
              onClick={() => {
                graph.current!.undo()
                graph.current!.undo()
              }}
            ></Button>
            <Button
              icon={<TbArrowForwardUp />}
              className={styles['topButtonCancel']}
              onClick={() => graph.current!.redo()}
            ></Button>
          </Space>
        </Space>
      </div>
      <div className={styles['sideButton']}>
        <Space direction="vertical" className={styles['sideButtonContent']}>
          <FaSearchPlus
            className={styles['sideIcon']}
            onClick={() => graph.current!.zoom(0.2)}
          ></FaSearchPlus>
          <FaSearchMinus
            className={styles['sideIcon']}
            onClick={() => graph.current!.zoom(-0.2)}
          ></FaSearchMinus>
          <AiOutlineFullscreenExit
            className={styles['sideIcon']}
            onClick={() => graph.current!.zoomToFit({ maxScale: 2 })}
          ></AiOutlineFullscreenExit>
        </Space>
      </div>
      <div
        id="antVX6Container"
        style={style}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop} // 放置事件
      ></div>
    </div>
  )
}

export default HomeTop

HomeTop.module.scss 

.topBar{
    display: flex;
    align-items: center;
    position: absolute;
    z-index: 1;
    background-color: white;  // 改为淡灰色
    height: 40px;
    border: 1px solid #ccc;    // 添加边框
    width: calc(100% - 280px);  // 宽度减小
}

.controlItem {
  display: flex;
  align-items: center;
  margin-right: 20px;
  padding: 8px 12px; // 添加内边距
  cursor: pointer;
  background-color: #fff; // 背景颜色
  transition: background-color 0.3s, box-shadow 0.3s; // 添加过渡效果
  border-radius: 8px; // 圆角
}
.controlItem:hover {
  background-color: #f0f0f0;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); // 悬停时增加阴影
}

.controlItem span {
  font-size: 16px;
  color: #333;
  font-weight: bold;
}
.controlItem i {
  display: flex;
  align-items: center;
  font-size: 21px;
  color: #333;
}


.topButton{
  position: absolute;
  z-index: 1;
  top: 110px;
}
.topButtonRun{
  border-radius: 16px;
  left: 20px;
  width: 60px !important;
  height: 60px;
  background-color: #0fdfb5 /* 设置背景为绿色 */;
  color: white /* 设置图标颜色为白色 */;
  font-size: 25px;
}

.topButtonCancel{
  border-radius: 16px;
  left: 20px;
  width: 60px !important;
  height: 60px;
  color: black /* 设置图标颜色为白色 */;
  font-size: 25px;  
}
.sideButton {
  position: absolute;
  z-index: 1;
  background-color: white;
  width: 50px;
  top: 180px;
  left: 305px;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 12px;
  cursor: pointer;  /* 让鼠标变为点击手势 */
  box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);  /* 初始阴影效果 */
  transition: all 0.3s ease;  /* 平滑过渡效果 */
}

/* 悬浮时的效果 */
.sideButton:hover {
  transform: scale(1.1);  /* 增大按钮尺寸 */
  box-shadow: 0px 6px 12px rgba(0, 0, 0, 0.2);  /* 增强阴影效果 */
}

/* 按下按钮时的效果 */
.sideButton:active {
  transform: scale(1);  /* 返回原本大小 */
  box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.1);  /* 按下时的阴影效果 */
}

/* 按钮图标样式 */
.sideIcon {
  margin-top: 15px;
  font-size: 20px;
  color: #333;
  font-weight: bold;
  transition: color 0.2s ease;  /* 图标颜色的过渡效果 */
}

/* 悬浮时图标颜色变化 */
.sideIcon:hover {
  color: #007bff;  /* 改变颜色为蓝色 */
}

 

AlgoNode.tsx

import './AlgoNode.css'
import { Graph, Node } from '@antv/x6'
import logo from '../../assets/antVX6NodeIcon/logo.png'
import running from '../../assets/antVX6NodeIcon/running.png'
import success from '../../assets/antVX6NodeIcon/success.png'
import failed from '../../assets/antVX6NodeIcon/failed.png'

interface NodeStatus {
  id: string
  label?: string
  status: 'default' | 'success' | 'failed' | 'running'
}
interface propsType {
  node: Node
  graph?: Graph
}
const image = {
  logo: logo,
  success: success,
  failed: failed,
  running: running,
}

const AlgoNode = (props: propsType) => {
  const { node } = props
  const data = node?.getData() as NodeStatus
  const { label, status = 'default' } = data

  return (
    <div className={`node ${status}`}>
      <img src={image.logo} alt="logo" />
      <span className="label">{label}</span>
      <span className="status">
        {status === 'success' && <img src={image.success} alt="success" />}
        {status === 'failed' && <img src={image.failed} alt="failed" />}
        {status === 'running' && <img src={image.running} alt="running" />}
      </span>
    </div>
  )
}

export default AlgoNode

AlgoNode.css

.node {
  display: flex;
  align-items: center;
  width: 100%;
  height: 100%;
  background-color: #fff;
  border: 1px solid #c2c8d5;
  border-left: 4px solid #5F95FF;
  border-radius: 4px;
  box-shadow: 0 2px 5px 1px rgba(0, 0, 0, 0.06);
}
.node img {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  margin-left: 8px;
}
.node .label {
  display: inline-block;
  flex-shrink: 0;
  width: 104px;
  margin-left: 8px;
  color: #666;
  font-size: 12px;
}
.node .status {
  flex-shrink: 0;
}
.node.success {
  border-left: 4px solid #52c41a;
}
.node.failed {
  border-left: 4px solid #ff4d4f;
}
.node.running .status img {
  animation: spin 1s linear infinite;
}
.x6-node-selected .node {
  border-color: #1890ff;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #d4e8fe;
}
.x6-node-selected .node.success {
  border-color: #52c41a;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #ccecc0;
}
.x6-node-selected .node.failed {
  border-color: #ff4d4f;
  border-radius: 2px;
  box-shadow: 0 0 0 4px #fedcdc;
}
.x6-edge:hover path:nth-child(2){
  stroke: #1890ff;
  stroke-width: 1px;
}

.x6-edge-selected path:nth-child(2){
  stroke: #1890ff;
  stroke-width: 1.5px !important;
}

@keyframes running-line {
  to {
    stroke-dashoffset: -1000;
  }
}
@keyframes spin {
  from {
      transform: rotate(0deg);
  }
  to {
      transform: rotate(360deg);
  }
}

 


网站公告

今日签到

点亮在社区的每一天
去签到