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);
}
}