diff --git a/.continue/prompts/comment.prompt b/.continue/prompts/comment.prompt old mode 100644 new mode 100755 diff --git a/.continue/prompts/error-handler.prompt b/.continue/prompts/error-handler.prompt deleted file mode 100644 index 2ec3d74..0000000 --- a/.continue/prompts/error-handler.prompt +++ /dev/null @@ -1,45 +0,0 @@ - -角色定位: -- 身份: 高级错误处理与诊断工程师 -- 专业能力: 深入系统异常分析与解决 -- 分析维度: 错误类型、根因追踪、修复策略 - -错误处理分析要求: -1. 错误详细诊断 - - 精确定位错误来源 - - 追踪完整错误调用链 - - 分析潜在影响范围 - -2. 错误分类与解析 - - 错误类型精确分类 - - 技术根因深度剖析 - - 系统架构潜在风险评估 - -3. 修复方案设计 - - 提供多层次修复建议 - - 评估每种方案的优缺点 - - 给出最优实施路径 - -4. 预防性建议 - - 提出系统防御性编程策略 - - 设计错误拦截与处理机制 - - 推荐代码健壮性改进方案 - -输出规范: -- 错误报告格式化文档 -- 中英文专业技术术语精准使用 -- 层次清晰、逻辑严密 -- 技术性、建设性并重 - -报告要素: -1. 错误摘要 -2. 详细诊断报告 -3. 根因分析 -4. 修复方案 -5. 预防建议 - -禁止: -- 避免泛泛而谈 -- 不提供无依据的猜测 -- 严格遵循技术分析逻辑 - diff --git a/.continue/prompts/explain.prompt b/.continue/prompts/explain.prompt old mode 100644 new mode 100755 diff --git a/.continue/prompts/jstots.prompt b/.continue/prompts/jstots.prompt old mode 100644 new mode 100755 diff --git a/.continue/prompts/react-refact.prompt b/.continue/prompts/react-refact.prompt deleted file mode 100644 index 4def8e2..0000000 --- a/.continue/prompts/react-refact.prompt +++ /dev/null @@ -1,39 +0,0 @@ -角色定位: -- 身份: 资深前端架构师 -- 专业能力: React组件设计与重构 -- 分析维度: 组件性能、可维护性、代码规范 - -重构分析要求: -1. 组件代码全面评估 -2. 重构目标: - - 提升组件渲染性能 - - 优化代码结构 - - 增强组件复用性 - - 遵循React最佳实践 - -重构评估维度: -- 状态管理是否合理 -- 渲染性能分析 -- Hook使用规范 -- 组件拆分颗粒度 -- 依赖管理 -- 类型安全 - -重构输出要求: -1. 详细重构方案 -2. 每个重构点需包含: - - 当前问题描述 - - 重构建议 - - 重构后代码示例 - - 性能/架构提升说明 - -重构原则: -- 保持原有业务逻辑不变 -- 代码简洁、可读性强 -- 遵循函数式编程思想 -- 类型安全优先 - -禁止: -- 过度工程化 -- 不切实际的重构 -- 损害可读性的过度抽象 \ No newline at end of file diff --git a/.continue/prompts/refact.prompt b/.continue/prompts/refact.prompt old mode 100644 new mode 100755 diff --git a/.continue/prompts/sci-post.prompt b/.continue/prompts/sci-post.prompt old mode 100644 new mode 100755 diff --git a/apps/web/package.json b/apps/web/package.json index c1acac4..a7fa5de 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,9 @@ "@ag-grid-enterprise/set-filter": "~32.3.2", "@ag-grid-enterprise/status-bar": "~32.3.2", "@ant-design/icons": "^5.4.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@floating-ui/react": "^0.26.25", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^3.9.1", @@ -38,6 +41,7 @@ "@trpc/client": "11.0.0-rc.456", "@trpc/react-query": "11.0.0-rc.456", "@trpc/server": "11.0.0-rc.456", + "@xyflow/react": "^12.3.6", "ag-grid-community": "~32.3.2", "ag-grid-enterprise": "~32.3.2", "ag-grid-react": "~32.3.2", @@ -46,7 +50,10 @@ "browser-image-compression": "^2.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-dag": "^1.1.0", + "d3-hierarchy": "^3.1.2", "dayjs": "^1.11.12", + "elkjs": "^0.9.3", "framer-motion": "^11.15.0", "hls.js": "^1.5.18", "idb-keyval": "^6.2.1", @@ -55,6 +62,7 @@ "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", + "react-dropzone": "^14.3.5", "react-hook-form": "^7.54.2", "react-hot-toast": "^2.4.1", "react-resizable": "^3.0.5", diff --git a/apps/web/src/app/main/home/page.tsx b/apps/web/src/app/main/home/page.tsx index 5a6d63e..fa47a49 100644 --- a/apps/web/src/app/main/home/page.tsx +++ b/apps/web/src/app/main/home/page.tsx @@ -1,3 +1,4 @@ +import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor'; import MindMapEditor from '@web/src/components/presentation/mind-map'; import React, { useState, useCallback } from 'react'; import * as tus from 'tus-js-client'; @@ -50,10 +51,14 @@ const TusUploader: React.FC = ({ return (
+
+ +
{/*
*/} - + {/* */} + { diff --git a/apps/web/src/components/common/container/Card.tsx b/apps/web/src/components/common/container/Card.tsx index f06a73a..2dbb089 100644 --- a/apps/web/src/components/common/container/Card.tsx +++ b/apps/web/src/components/common/container/Card.tsx @@ -1,4 +1,3 @@ -import { motion } from 'framer-motion'; import { ReactNode } from 'react'; interface CardProps { @@ -6,23 +5,37 @@ interface CardProps { className?: string; hover?: boolean; onClick?: () => void; + variant?: 'default' | 'elevated' | 'outlined'; } -export const Card = ({ children, className = '', hover = true, onClick }: CardProps) => { +export const Card = ({ + children, + className = '', + hover = true, + onClick, + variant = 'default' +}: CardProps) => { + const baseStyles = 'rounded-xl transition-all duration-300'; + + const variantStyles = { + default: 'bg-white/90 border border-gray-100 shadow-sm', + elevated: 'bg-gradient-to-br from-white/95 to-white/90 shadow-lg border border-gray-50', + outlined: 'bg-transparent border-2 border-gray-200 hover:border-gray-300' + }; + + + return ( - {children} - +
); }; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/GraphEditor.tsx b/apps/web/src/components/common/editor/graph/GraphEditor.tsx new file mode 100644 index 0000000..633bab7 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/GraphEditor.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { addEdge, ReactFlow, Background, Controls, Edge, Node, ReactFlowProvider, useEdgesState, useNodesState, MiniMap, Panel, BackgroundVariant, ControlButton, applyNodeChanges, applyEdgeChanges, SelectionMode, OnNodesChange, OnEdgesChange, useReactFlow, useOnSelectionChange, useNodesInitialized } from '@xyflow/react'; +import { Button } from '../../element/Button'; +import '@xyflow/react/dist/style.css'; +import { edgeTypes, GraphState, nodeTypes } from './types'; +import useGraphStore from './store'; +import { shallow } from 'zustand/shallow'; +import { useKeyboardCtrl } from './useKeyboardCtrl'; +import { getMindMapLayout } from './layout'; + + + + +const selector = (store: GraphState) => ({ + nodes: store.present.nodes, + edges: store.present.edges, + setNodes: store.setNodes, + setEdges: store.setEdges, + record: store.record, + onNodesChange: store.onNodesChange, + onEdgesChange: store.onEdgesChange, +}); + +const panOnDrag = [1, 2]; + +const Flow: React.FC = () => { + + const store = useGraphStore(selector, shallow); + useKeyboardCtrl() + const nodesInitialized = useNodesInitialized(); + const onLayout = useCallback(async () => { + const layouted = getMindMapLayout({ nodes: store.nodes, edges: store.edges }) + store.setNodes(layouted.nodes) + store.setEdges(layouted.edges) + }, [store.nodes, store.edges]); + useEffect(() => { + if (nodesInitialized && store.nodes.length) { + console.log('layout') + onLayout() + } + }, [nodesInitialized, store.nodes.length]); + + return ( + { + const recordTypes = new Set(['remove', 'select']); + const undoChanges = changes.filter(change => recordTypes.has(change.type)) + const otherChanges = changes.filter(change => !recordTypes.has(change.type)) + if (undoChanges.length) + store.record(() => { + store.onNodesChange(undoChanges); + }); + store.onNodesChange(otherChanges); + }} + onEdgesChange={(changes) => { + const recordTypes = new Set(['remove', 'select']); + changes.forEach((change) => { + if (recordTypes.has(change.type)) { + store.record(() => { + store.onEdgesChange([change]); + }); + } else { + store.onEdgesChange([change]); + } + }); + }} + selectionOnDrag + panOnDrag={panOnDrag} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + selectionMode={SelectionMode.Partial} + fitView + minZoom={0.001} + maxZoom={1000} + > + +
+ + 节点个数{store.nodes.length} + 边条数{store.edges.length} +
+ +
+ + + 测试 + + +
+ ); +}; + +const GraphEditor: React.FC = () => { + return ( + + + + ); +}; + +export default GraphEditor; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/data.ts b/apps/web/src/components/common/editor/graph/data.ts new file mode 100644 index 0000000..c358f7a --- /dev/null +++ b/apps/web/src/components/common/editor/graph/data.ts @@ -0,0 +1,57 @@ +import { MarkerType } from "@xyflow/react"; + +// 生成思维导图数据的函数 +function generateMindMapData(levels: number, nodesPerLevel: number) { + const nodes = []; + const edges = []; + + // 添加根节点 + nodes.push({ + id: 'root', + data: { label: '核心主题', level: 0 }, + type: 'graph-node', + position: { x: 0, y: 0 } + }); + + // 为每一层生成节点 + for (let level = 1; level <= levels; level++) { + const angleStep = (2 * Math.PI) / nodesPerLevel; + const radius = level * 200; // 每层的半径 + + for (let i = 0; i < nodesPerLevel; i++) { + const angle = i * angleStep; + const nodeId = `node-${level}-${i}`; + + // 计算节点位置 + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + // 添加节点 + nodes.push({ + id: nodeId, + data: { label: `主题${level}-${i}`, level }, + type: 'graph-node', + position: { x, y } + }); + + // 添加边 + // 第一层连接到根节点,其他层连接到上一层的节点 + const sourceId = level === 1 ? 'root' : `node-${level - 1}-${Math.floor(i / 2)}`; + edges.push({ + id: `edge-${level}-${i}`, + source: sourceId, + target: nodeId, + type: 'graph-edge', + }); + } + } + + return { nodes, edges }; +} + +// 生成测试数据 - 可以调整参数来控制规模 +// 参数1: 层级数量 +// 参数2: 每层节点数量 +const { nodes: initialNodes, edges: initialEdges } = generateMindMapData(2, 3); + +export { initialNodes, initialEdges }; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx b/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx new file mode 100644 index 0000000..7224da2 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/edges/FloatEdge.tsx @@ -0,0 +1,47 @@ +import { EdgeProps, getBezierPath, useInternalNode } from '@xyflow/react'; +import { getEdgeParams } from '../utils'; + +/** + * FloatingEdge 组件用于渲染图中的浮动边。 + * 该组件通过计算源节点和目标节点的位置,生成贝塞尔曲线路径,并渲染为SVG路径元素。 + * 适用于需要自定义边样式的图结构可视化场景。 + */ +function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) { + // 使用 useInternalNode 钩子获取源节点和目标节点的内部节点信息 + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + // 如果源节点或目标节点不存在,则不渲染任何内容 + if (!sourceNode || !targetNode) { + return null; + } + + // 获取边的参数,包括源节点和目标节点的坐标及位置信息 + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + ); + + // 使用 getBezierPath 函数生成贝塞尔曲线路径 + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + // 返回 SVG 路径元素,表示图中的边 + return ( + + ); +} + +export default FloatingEdge; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx new file mode 100644 index 0000000..f7da3d2 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx @@ -0,0 +1,44 @@ +import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, useReactFlow } from '@xyflow/react'; + +export type GraphEdge = Edge<{ text: string }, 'graph-edge'>; + +export const GraphEdge = ({ id, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps) => { + const { setEdges } = useReactFlow(); + // 使用贝塞尔曲线代替直线,让连线更流畅 + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + }); + + return ( + <> + + {/* 添加边的标签渲染器 */} + + {data?.text && ( +
+ {data.text} +
+ )} +
+ + ); +}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/algorithms/a-star.ts b/apps/web/src/components/common/editor/graph/edges/algorithms/a-star.ts new file mode 100644 index 0000000..c167657 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/edges/algorithms/a-star.ts @@ -0,0 +1,219 @@ +import { areLinesReverseDirection, areLinesSameDirection } from "../edge"; +import { + ControlPoint, + NodeRect, + isEqualPoint, + isSegmentCrossingRect, +} from "../point"; + +interface GetAStarPathParams { + /** + * Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points. + */ + points: ControlPoint[]; + source: ControlPoint; + target: ControlPoint; + /** + * Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes. + */ + sourceRect: NodeRect; + targetRect: NodeRect; +} + +/** + * Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with + * [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges. + * + * @returns Control points including sourceOffset and targetOffset (not including source and target points). + */ +export const getAStarPath = ({ + points, + source, + target, + sourceRect, + targetRect, +}: GetAStarPathParams): ControlPoint[] => { + if (points.length < 3) { + return points; + } + const start = points[0]; + const end = points[points.length - 1]; + const openSet: ControlPoint[] = [start]; + const closedSet: Set = new Set(); + const cameFrom: Map = new Map(); + const gScore: Map = new Map().set(start, 0); + const fScore: Map = new Map().set( + start, + heuristicCostEstimate({ + from: start, + to: start, + start, + end, + source, + target, + }) + ); + + while (openSet.length) { + let current; + let currentIdx; + let lowestFScore = Infinity; + openSet.forEach((p, idx) => { + const score = fScore.get(p) ?? 0; + if (score < lowestFScore) { + lowestFScore = score; + current = p; + currentIdx = idx; + } + }); + + if (!current) { + break; + } + + if (current === end) { + return buildPath(cameFrom, current); + } + + openSet.splice(currentIdx!, 1); + closedSet.add(current); + + const curFScore = fScore.get(current) ?? 0; + const previous = cameFrom.get(current); + const neighbors = getNextNeighborPoints({ + points, + previous, + current, + sourceRect, + targetRect, + }); + for (const neighbor of neighbors) { + if (closedSet.has(neighbor)) { + continue; + } + const neighborGScore = gScore.get(neighbor) ?? 0; + const tentativeGScore = curFScore + estimateDistance(current, neighbor); + if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) { + continue; + } + openSet.push(neighbor); + cameFrom.set(neighbor, current); + gScore.set(neighbor, tentativeGScore); + fScore.set( + neighbor, + neighborGScore + + heuristicCostEstimate({ + from: current, + to: neighbor, + start, + end, + source, + target, + }) + ); + } + } + return [start, end]; +}; + +const buildPath = ( + cameFrom: Map, + current: ControlPoint +): ControlPoint[] => { + const path = [current]; + + let previous = cameFrom.get(current); + while (previous) { + path.push(previous); + previous = cameFrom.get(previous); + } + + return path.reverse(); +}; + +interface GetNextNeighborPointsParams { + points: ControlPoint[]; + previous?: ControlPoint; + current: ControlPoint; + sourceRect: NodeRect; + targetRect: NodeRect; +} + +/** + * Get the set of possible neighboring points for the current control point + * + * - The line is in a horizontal or vertical direction + * - The line does not intersect with the two end nodes + * - The line does not overlap with the previous line segment in reverse direction + */ +export const getNextNeighborPoints = ({ + points, + previous, + current, + sourceRect, + targetRect, +}: GetNextNeighborPointsParams): ControlPoint[] => { + return points.filter((p) => { + if (p === current) { + return false; + } + // The connection is in the horizontal or vertical direction + const rightDirection = p.x === current.x || p.y === current.y; + // Reverse direction with the previous line segment (overlap) + const reverseDirection = previous + ? areLinesReverseDirection(previous, current, current, p) + : false; + return ( + rightDirection && // The line is in a horizontal or vertical direction + !reverseDirection && // The line does not overlap with the previous line segment in reverse direction + !isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode + !isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode + ); + }); +}; + +interface HeuristicCostParams { + from: ControlPoint; + to: ControlPoint; + start: ControlPoint; + end: ControlPoint; + source: ControlPoint; + target: ControlPoint; +} + +/** + * Connection point distance loss function + * + * - The smaller the sum of distances, the better + * - The closer the start and end line segments are in direction, the better + * - The better the inflection point is symmetric or centered in the line segment + */ +const heuristicCostEstimate = ({ + from, + to, + start, + end, + source, + target, +}: HeuristicCostParams): number => { + const base = estimateDistance(to, start) + estimateDistance(to, end); + const startCost = isEqualPoint(from, start) + ? areLinesSameDirection(from, to, source, start) + ? -base / 2 + : 0 + : 0; + const endCost = isEqualPoint(to, end) + ? areLinesSameDirection(from, to, end, target) + ? -base / 2 + : 0 + : 0; + return base + startCost + endCost; +}; + +/** + * Calculate the estimated distance between two points + * + * Manhattan distance: the sum of horizontal and vertical distances, faster calculation speed + */ +const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number => + Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); diff --git a/apps/web/src/components/common/editor/graph/edges/algorithms/index.ts b/apps/web/src/components/common/editor/graph/edges/algorithms/index.ts new file mode 100644 index 0000000..a9fd894 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/edges/algorithms/index.ts @@ -0,0 +1,127 @@ +import { areLinesSameDirection, isHorizontalFromPosition } from "../edge"; +import { + ControlPoint, + HandlePosition, + NodeRect, + getCenterPoints, + getExpandedRect, + getOffsetPoint, + getSidesFromPoints, + getVerticesFromRectVertex, + optimizeInputPoints, + reducePoints, +} from "../point"; +import { getAStarPath } from "./a-star"; +import { getSimplePath } from "./simple"; + +export interface GetControlPointsParams { + source: HandlePosition; + target: HandlePosition; + sourceRect: NodeRect; + targetRect: NodeRect; + /** + * Minimum spacing between edges and nodes + */ + offset: number; +} + +/** + * Calculate control points on the optimal path of an edge. + * + * Reference article: https://juejin.cn/post/6942727734518874142 + */ +export const getControlPoints = ({ + source: oldSource, + target: oldTarget, + sourceRect, + targetRect, + offset = 20, +}: GetControlPointsParams) => { + const source: ControlPoint = oldSource; + const target: ControlPoint = oldTarget; + let edgePoints: ControlPoint[] = []; + let optimized: ReturnType; + + // 1. Find the starting and ending points after applying the offset + const sourceOffset = getOffsetPoint(oldSource, offset); + const targetOffset = getOffsetPoint(oldTarget, offset); + const expandedSource = getExpandedRect(sourceRect, offset); + const expandedTarget = getExpandedRect(targetRect, offset); + + // 2. Determine if the two Rects are relatively close or should directly connected + const minOffset = 2 * offset + 10; + const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); + const isSameDirection = areLinesSameDirection( + source, + sourceOffset, + targetOffset, + target + ); + const sides = getSidesFromPoints([ + source, + target, + sourceOffset, + targetOffset, + ]); + const isTooClose = isHorizontalLayout + ? sides.right - sides.left < minOffset + : sides.bottom - sides.top < minOffset; + const isDirectConnect = isHorizontalLayout + ? isSameDirection && source.x < target.x + : isSameDirection && source.y < target.y; + + if (isTooClose || isDirectConnect) { + // 3. If the two Rects are relatively close or directly connected, return a simple Path + edgePoints = getSimplePath({ + source, + target, + sourceOffset, + targetOffset, + isDirectConnect, + }); + optimized = optimizeInputPoints({ + source: oldSource, + target: oldTarget, + sourceOffset, + targetOffset, + edgePoints, + }); + edgePoints = optimized.edgePoints; + } else { + // 3. Find the vertices of the two expanded Rects + edgePoints = [ + ...getVerticesFromRectVertex(expandedSource, targetOffset), + ...getVerticesFromRectVertex(expandedTarget, sourceOffset), + ]; + // 4. Find possible midpoints and intersections + edgePoints = edgePoints.concat( + getCenterPoints({ + source: expandedSource, + target: expandedTarget, + sourceOffset, + targetOffset, + }) + ); + // 5. Merge nearby coordinate points and remove duplicate coordinate points + optimized = optimizeInputPoints({ + source: oldSource, + target: oldTarget, + sourceOffset, + targetOffset, + edgePoints, + }); + // 6. Find the optimal path + edgePoints = getAStarPath({ + points: optimized.edgePoints, + source: optimized.source, + target: optimized.target, + sourceRect: getExpandedRect(sourceRect, offset / 2), + targetRect: getExpandedRect(targetRect, offset / 2), + }); + } + + return { + points: reducePoints([optimized.source, ...edgePoints, optimized.target]), + inputPoints: optimized.edgePoints, + }; +}; diff --git a/apps/web/src/components/common/editor/graph/edges/algorithms/simple.ts b/apps/web/src/components/common/editor/graph/edges/algorithms/simple.ts new file mode 100644 index 0000000..4526ef4 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/edges/algorithms/simple.ts @@ -0,0 +1,113 @@ +import { uuid } from "@/utils/uuid"; + +import { LayoutDirection } from "../../node"; +import { ControlPoint, isInLine, isOnLine } from "../point"; + +interface GetSimplePathParams { + isDirectConnect?: boolean; + source: ControlPoint; + target: ControlPoint; + sourceOffset: ControlPoint; + targetOffset: ControlPoint; +} + +const getLineDirection = ( + start: ControlPoint, + end: ControlPoint +): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal"); + +/** + * When two nodes are too close, use the simple path + * + * @returns Control points including sourceOffset and targetOffset (not including source and target points). + */ +export const getSimplePath = ({ + isDirectConnect, + source, + target, + sourceOffset, + targetOffset, +}: GetSimplePathParams): ControlPoint[] => { + const points: ControlPoint[] = []; + const sourceDirection = getLineDirection(source, sourceOffset); + const targetDirection = getLineDirection(target, targetOffset); + const isHorizontalLayout = sourceDirection === "horizontal"; + if (isDirectConnect) { + // Direct connection, return a simple Path + if (isHorizontalLayout) { + if (sourceOffset.x <= targetOffset.x) { + const centerX = (sourceOffset.x + targetOffset.x) / 2; + return [ + { id: uuid(), x: centerX, y: sourceOffset.y }, + { id: uuid(), x: centerX, y: targetOffset.y }, + ]; + } else { + const centerY = (sourceOffset.y + targetOffset.y) / 2; + return [ + sourceOffset, + { id: uuid(), x: sourceOffset.x, y: centerY }, + { id: uuid(), x: targetOffset.x, y: centerY }, + targetOffset, + ]; + } + } else { + if (sourceOffset.y <= targetOffset.y) { + const centerY = (sourceOffset.y + targetOffset.y) / 2; + return [ + { id: uuid(), x: sourceOffset.x, y: centerY }, + { id: uuid(), x: targetOffset.x, y: centerY }, + ]; + } else { + const centerX = (sourceOffset.x + targetOffset.x) / 2; + return [ + sourceOffset, + { id: uuid(), x: centerX, y: sourceOffset.y }, + { id: uuid(), x: centerX, y: targetOffset.y }, + targetOffset, + ]; + } + } + } + if (sourceDirection === targetDirection) { + // Same direction, add two points, two endpoints of parallel lines at half the vertical distance + if (source.y === sourceOffset.y) { + points.push({ + id: uuid(), + x: sourceOffset.x, + y: (sourceOffset.y + targetOffset.y) / 2, + }); + points.push({ + id: uuid(), + x: targetOffset.x, + y: (sourceOffset.y + targetOffset.y) / 2, + }); + } else { + points.push({ + id: uuid(), + x: (sourceOffset.x + targetOffset.x) / 2, + y: sourceOffset.y, + }); + points.push({ + id: uuid(), + x: (sourceOffset.x + targetOffset.x) / 2, + y: targetOffset.y, + }); + } + } else { + // Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns + let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y }; + const inStart = isInLine(point, source, sourceOffset); + const inEnd = isInLine(point, target, targetOffset); + if (inStart || inEnd) { + point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; + } else { + const onStart = isOnLine(point, source, sourceOffset); + const onEnd = isOnLine(point, target, targetOffset); + if (onStart && onEnd) { + point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; + } + } + points.push(point); + } + return [sourceOffset, ...points, targetOffset]; +}; diff --git a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/a-star.ts b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/a-star.ts new file mode 100644 index 0000000..4b71dea --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/a-star.ts @@ -0,0 +1,248 @@ +import { areLinesReverseDirection, areLinesSameDirection } from "../edge"; +import { + ControlPoint, + NodeRect, + isEqualPoint, + isSegmentCrossingRect, +} from "../point"; + +interface GetAStarPathParams { + /** + * Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points. + */ + points: ControlPoint[]; + source: ControlPoint; + target: ControlPoint; + /** + * Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes. + */ + sourceRect: NodeRect; + targetRect: NodeRect; +} + +/** + * Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with + * [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges. + * + * @returns Control points including sourceOffset and targetOffset (not including source and target points). + */ +export const getAStarPath = ({ + points, + source, + target, + sourceRect, + targetRect, +}: GetAStarPathParams): ControlPoint[] => { + if (points.length < 3) { + return points; + } + const start = points[0]; + const end = points[points.length - 1]; + const openSet: ControlPoint[] = [start]; + const closedSet: Set = new Set(); + const cameFrom: Map = new Map(); + const gScore: Map = new Map().set(start, 0); + const fScore: Map = new Map().set( + start, + heuristicCostEstimate({ + from: start, + to: start, + start, + end, + source, + target, + }) + ); + + while (openSet.length) { + let current; + let currentIdx; + let lowestFScore = Infinity; + openSet.forEach((p, idx) => { + const score = fScore.get(p) ?? 0; + if (score < lowestFScore) { + lowestFScore = score; + current = p; + currentIdx = idx; + } + }); + + if (!current) { + break; + } + + if (current === end) { + return buildPath(cameFrom, current); + } + + openSet.splice(currentIdx!, 1); + closedSet.add(current); + + const curFScore = fScore.get(current) ?? 0; + const previous = cameFrom.get(current); + const neighbors = getNextNeighborPoints({ + points, + previous, + current, + sourceRect, + targetRect, + }); + for (const neighbor of neighbors) { + if (closedSet.has(neighbor)) { + continue; + } + const neighborGScore = gScore.get(neighbor) ?? 0; + const tentativeGScore = curFScore + estimateDistance(current, neighbor); + if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) { + continue; + } + openSet.push(neighbor); + cameFrom.set(neighbor, current); + gScore.set(neighbor, tentativeGScore); + fScore.set( + neighbor, + neighborGScore + + heuristicCostEstimate({ + from: current, + to: neighbor, + start, + end, + source, + target, + }) + ); + } + } + return [start, end]; +}; + +const buildPath = ( + cameFrom: Map, + current: ControlPoint +): ControlPoint[] => { + const path = [current]; + + let previous = cameFrom.get(current); + while (previous) { + path.push(previous); + previous = cameFrom.get(previous); + } + + return path.reverse(); +}; + +interface GetNextNeighborPointsParams { + points: ControlPoint[]; + previous?: ControlPoint; + current: ControlPoint; + sourceRect: NodeRect; + targetRect: NodeRect; +} + +/** + * Get the set of possible neighboring points for the current control point + * + * - The line is in a horizontal or vertical direction + * - The line does not intersect with the two end nodes + * - The line does not overlap with the previous line segment in reverse direction + */ +export const getNextNeighborPoints = ({ + points, + previous, + current, + sourceRect, + targetRect, +}: GetNextNeighborPointsParams): ControlPoint[] => { + return points.filter((p) => { + if (p === current) { + return false; + } + // The connection is in the horizontal or vertical direction + const rightDirection = p.x === current.x || p.y === current.y; + // Reverse direction with the previous line segment (overlap) + const reverseDirection = previous + ? areLinesReverseDirection(previous, current, current, p) + : false; + return ( + rightDirection && // The line is in a horizontal or vertical direction + !reverseDirection && // The line does not overlap with the previous line segment in reverse direction + !isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode + !isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode + ); + }); +}; + +/** + * 路径规划所需的启发式代价计算参数接口 + * 包含了计算路径代价所需的所有控制点信息: + * - from/to: 当前路径段的起点和终点 + * - start/end: 整条路径的起点和终点 + * - source/target: 连接的源节点和目标节点位置 + */ +interface HeuristicCostParams { + from: ControlPoint; // 当前路径段的起点 + to: ControlPoint; // 当前路径段的终点 + start: ControlPoint; // 整条路径的起始点 + end: ControlPoint; // 整条路径的终点 + source: ControlPoint; // 源节点的连接点 + target: ControlPoint; // 目标节点的连接点 +} + +/** + * 启发式路径代价估算函数 + * + * 该函数通过多个因素综合评估路径的优劣程度: + * 1. 基础代价: 当前点到起点和终点的曼哈顿距离之和 + * 2. 起点优化: 如果是起始段,判断方向一致性给予奖励 + * 3. 终点优化: 如果是结束段,判断方向一致性给予奖励 + * + * 优化目标: + * - 减少路径总长度 + * - 保持路径走向的连续性 + * - 使拐点在路径中更均匀分布 + * + * @param params 包含所有必要控制点的参数对象 + * @returns 计算得到的启发式代价值,值越小路径越优 + */ +const heuristicCostEstimate = ({ + from, + to, + start, + end, + source, + target, +}: HeuristicCostParams): number => { + // 计算基础代价 - 到起点和终点的距离之和 + const base = estimateDistance(to, start) + estimateDistance(to, end); + + // 起点方向优化 - 如果是起始段且方向一致,给予奖励 + const startCost = isEqualPoint(from, start) + ? areLinesSameDirection(from, to, source, start) + ? -base / 2 // 方向一致时减少代价 + : 0 + : 0; + + // 终点方向优化 - 如果是结束段且方向一致,给予奖励 + const endCost = isEqualPoint(to, end) + ? areLinesSameDirection(from, to, end, target) + ? -base / 2 // 方向一致时减少代价 + : 0 + : 0; + + return base + startCost + endCost; +}; + +/** + * 计算两点间的估计距离 + * + * 采用曼哈顿距离(Manhattan distance)计算: + * - 只计算水平和垂直方向的距离之和 + * - 避免使用欧几里得距离的开方运算 + * - 在网格化的路径规划中性能更优 + * + * @param p1 第一个控制点 + * @param p2 第二个控制点 + * @returns 两点间的曼哈顿距离 + */ +const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number => + Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/index.ts b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/index.ts new file mode 100644 index 0000000..8530033 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/index.ts @@ -0,0 +1,142 @@ +import { areLinesSameDirection, isHorizontalFromPosition } from "../edge"; +import { + ControlPoint, + HandlePosition, + NodeRect, + getCenterPoints, + getExpandedRect, + getOffsetPoint, + getSidesFromPoints, + getVerticesFromRectVertex, + optimizeInputPoints, + reducePoints, +} from "../point"; +import { getAStarPath } from "./a-star"; +import { getSimplePath } from "./simple"; + +/** + * 边缘控制点计算模块 + * 用于计算图形边缘连接线的控制点,以实现平滑的连接效果 + * 主要应用于流程图、思维导图等需要节点间连线的场景 + */ + +/** + * 控制点计算所需的输入参数接口 + */ +export interface GetControlPointsParams { + source: HandlePosition; // 起始连接点位置 + target: HandlePosition; // 目标连接点位置 + sourceRect: NodeRect; // 起始节点的矩形区域 + targetRect: NodeRect; // 目标节点的矩形区域 + /** + * 边缘与节点之间的最小间距 + * @default 20 + */ + offset: number; +} + +/** + * 计算两个节点之间连接线的控制点 + * @param params 控制点计算参数 + * @returns 返回优化后的路径点和输入点集合 + */ +export const getControlPoints = ({ + source: oldSource, + target: oldTarget, + sourceRect, + targetRect, + offset = 20, +}: GetControlPointsParams) => { + const source: ControlPoint = oldSource; + const target: ControlPoint = oldTarget; + let edgePoints: ControlPoint[] = []; + let optimized: ReturnType; + + // 1. 计算考虑偏移量后的起始和结束点 + const sourceOffset = getOffsetPoint(oldSource, offset); + const targetOffset = getOffsetPoint(oldTarget, offset); + const expandedSource = getExpandedRect(sourceRect, offset); + const expandedTarget = getExpandedRect(targetRect, offset); + + // 2. 判断两个矩形是否靠得较近或应该直接连接 + const minOffset = 2 * offset + 10; // 最小间距阈值 + const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); // 是否为水平布局 + const isSameDirection = areLinesSameDirection( + source, + sourceOffset, + targetOffset, + target + ); // 判断是否同向 + const sides = getSidesFromPoints([ + source, + target, + sourceOffset, + targetOffset, + ]); // 获取边界信息 + + // 判断节点是否过近 + const isTooClose = isHorizontalLayout + ? sides.right - sides.left < minOffset + : sides.bottom - sides.top < minOffset; + // 判断是否可以直接连接 + const isDirectConnect = isHorizontalLayout + ? isSameDirection && source.x < target.x + : isSameDirection && source.y < target.y; + + if (isTooClose || isDirectConnect) { + // 3. 如果节点较近或可直接连接,返回简单路径 + edgePoints = getSimplePath({ + source, + target, + sourceOffset, + targetOffset, + isDirectConnect, + }); + // 优化输入点 + optimized = optimizeInputPoints({ + source: oldSource, + target: oldTarget, + sourceOffset, + targetOffset, + edgePoints, + }); + edgePoints = optimized.edgePoints; + } else { + // 3. 获取两个扩展矩形的顶点 + edgePoints = [ + ...getVerticesFromRectVertex(expandedSource, targetOffset), + ...getVerticesFromRectVertex(expandedTarget, sourceOffset), + ]; + // 4. 计算可能的中点和交点 + edgePoints = edgePoints.concat( + getCenterPoints({ + source: expandedSource, + target: expandedTarget, + sourceOffset, + targetOffset, + }) + ); + // 5. 合并临近坐标点并去除重复点 + optimized = optimizeInputPoints({ + source: oldSource, + target: oldTarget, + sourceOffset, + targetOffset, + edgePoints, + }); + // 6. 使用A*算法寻找最优路径 + edgePoints = getAStarPath({ + points: optimized.edgePoints, + source: optimized.source, + target: optimized.target, + sourceRect: getExpandedRect(sourceRect, offset / 2), + targetRect: getExpandedRect(targetRect, offset / 2), + }); + } + + // 返回简化后的路径点和输入点集合 + return { + points: reducePoints([optimized.source, ...edgePoints, optimized.target]), + inputPoints: optimized.edgePoints, + }; +}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/simple.ts b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/simple.ts new file mode 100644 index 0000000..8545fd2 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/simple.ts @@ -0,0 +1,112 @@ +import { uuid } from "../../../utils/uuid"; +import { LayoutDirection } from "../../node"; +import { ControlPoint, isInLine, isOnLine } from "../point"; + +interface GetSimplePathParams { + isDirectConnect?: boolean; + source: ControlPoint; + target: ControlPoint; + sourceOffset: ControlPoint; + targetOffset: ControlPoint; +} + +const getLineDirection = ( + start: ControlPoint, + end: ControlPoint +): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal"); + +/** + * When two nodes are too close, use the simple path + * + * @returns Control points including sourceOffset and targetOffset (not including source and target points). + */ +export const getSimplePath = ({ + isDirectConnect, + source, + target, + sourceOffset, + targetOffset, +}: GetSimplePathParams): ControlPoint[] => { + const points: ControlPoint[] = []; + const sourceDirection = getLineDirection(source, sourceOffset); + const targetDirection = getLineDirection(target, targetOffset); + const isHorizontalLayout = sourceDirection === "horizontal"; + if (isDirectConnect) { + // Direct connection, return a simple Path + if (isHorizontalLayout) { + if (sourceOffset.x <= targetOffset.x) { + const centerX = (sourceOffset.x + targetOffset.x) / 2; + return [ + { id: uuid(), x: centerX, y: sourceOffset.y }, + { id: uuid(), x: centerX, y: targetOffset.y }, + ]; + } else { + const centerY = (sourceOffset.y + targetOffset.y) / 2; + return [ + sourceOffset, + { id: uuid(), x: sourceOffset.x, y: centerY }, + { id: uuid(), x: targetOffset.x, y: centerY }, + targetOffset, + ]; + } + } else { + if (sourceOffset.y <= targetOffset.y) { + const centerY = (sourceOffset.y + targetOffset.y) / 2; + return [ + { id: uuid(), x: sourceOffset.x, y: centerY }, + { id: uuid(), x: targetOffset.x, y: centerY }, + ]; + } else { + const centerX = (sourceOffset.x + targetOffset.x) / 2; + return [ + sourceOffset, + { id: uuid(), x: centerX, y: sourceOffset.y }, + { id: uuid(), x: centerX, y: targetOffset.y }, + targetOffset, + ]; + } + } + } + if (sourceDirection === targetDirection) { + // Same direction, add two points, two endpoints of parallel lines at half the vertical distance + if (source.y === sourceOffset.y) { + points.push({ + id: uuid(), + x: sourceOffset.x, + y: (sourceOffset.y + targetOffset.y) / 2, + }); + points.push({ + id: uuid(), + x: targetOffset.x, + y: (sourceOffset.y + targetOffset.y) / 2, + }); + } else { + points.push({ + id: uuid(), + x: (sourceOffset.x + targetOffset.x) / 2, + y: sourceOffset.y, + }); + points.push({ + id: uuid(), + x: (sourceOffset.x + targetOffset.x) / 2, + y: targetOffset.y, + }); + } + } else { + // Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns + let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y }; + const inStart = isInLine(point, source, sourceOffset); + const inEnd = isInLine(point, target, targetOffset); + if (inStart || inEnd) { + point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; + } else { + const onStart = isOnLine(point, source, sourceOffset); + const onEnd = isOnLine(point, target, targetOffset); + if (onStart && onEnd) { + point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; + } + } + points.push(point); + } + return [sourceOffset, ...points, targetOffset]; +}; diff --git a/apps/web/src/components/common/editor/graph/layout/edge/edge.ts b/apps/web/src/components/common/editor/graph/layout/edge/edge.ts new file mode 100644 index 0000000..0f25f05 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/edge.ts @@ -0,0 +1,389 @@ +import { Position, XYPosition } from "@xyflow/react"; +import { ControlPoint, HandlePosition } from "./point"; +import { uuid } from "../../utils/uuid"; + +export interface ILine { + start: ControlPoint; + end: ControlPoint; +} + +/** + * 判断给定位置是否为水平方向 + * @param position - 位置枚举值 + * @returns 如果是左侧或右侧位置则返回true,否则返回false + */ +export const isHorizontalFromPosition = (position: Position) => { + return [Position.Left, Position.Right].includes(position); +}; + +/** + * 判断连接是否为反向 + * 在图形布局中,通常希望连线从左到右或从上到下。当连线方向与此相反时,即为反向连接 + * @param props - 包含源点和目标点位置信息的对象 + * @param props.source - 源节点的位置信息 + * @param props.target - 目标节点的位置信息 + * @returns 如果是反向连接则返回true,否则返回false + */ +export const isConnectionBackward = (props: { + source: HandlePosition; + target: HandlePosition; +}) => { + const { source, target } = props; + // 判断是水平还是垂直方向的连接 + const isHorizontal = isHorizontalFromPosition(source.position); + let isBackward = false; + + // 水平方向时,如果源点x坐标大于目标点,则为反向 + if (isHorizontal) { + if (source.x > target.x) { + isBackward = true; + } + } + // 垂直方向时,如果源点y坐标大于目标点,则为反向 + else { + if (source.y > target.y) { + isBackward = true; + } + } + return isBackward; +}; + +/** + * 计算两点之间的欧几里得距离 + * 使用勾股定理(Math.hypot)计算两点间的直线距离 + * @param p1 - 第一个控制点 + * @param p2 - 第二个控制点 + * @returns 两点间的距离 + */ +export const distance = (p1: ControlPoint, p2: ControlPoint) => { + return Math.hypot(p2.x - p1.x, p2.y - p1.y); +}; + +/** + * 计算线段的中点坐标 + * 通过取两端点坐标的算术平均值来确定中点位置 + * @param p1 - 第一个控制点 + * @param p2 - 第二个控制点 + * @returns 包含中点坐标和唯一标识的控制点对象 + */ +export const getLineCenter = ( + p1: ControlPoint, + p2: ControlPoint +): ControlPoint => { + return { + id: uuid(), + x: (p1.x + p2.x) / 2, // x坐标取两端点x坐标的平均值 + y: (p1.y + p2.y) / 2, // y坐标取两端点y坐标的平均值 + }; +}; + +/** + * 判断点是否在线段上 + * + * @description + * 该函数用于检测给定的点是否位于由两个控制点构成的线段上。 + * 判断逻辑分为两种情况: + * 1. 垂直线段: 当起点和终点的x坐标相同时,判断目标点的x坐标是否等于线段x坐标,且y坐标在线段y坐标范围内 + * 2. 水平线段: 当起点和终点的y坐标相同时,判断目标点的y坐标是否等于线段y坐标,且x坐标在线段x坐标范围内 + * + * @param start - 线段起点坐标 + * @param end - 线段终点坐标 + * @param p - 待检测点的坐标 + * @returns {boolean} 如果点在线段上返回true,否则返回false + */ +export const isLineContainsPoint = ( + start: ControlPoint, + end: ControlPoint, + p: ControlPoint +) => { + return ( + // 判断垂直线段 + (start.x === end.x && // 起点终点x坐标相同 + p.x === start.x && // 目标点x坐标与线段相同 + p.y <= Math.max(start.y, end.y) && // 目标点y坐标不超过线段y坐标最大值 + p.y >= Math.min(start.y, end.y)) || // 目标点y坐标不小于线段y坐标最小值 + // 判断水平线段 + (start.y === end.y && // 起点终点y坐标相同 + p.y === start.y && // 目标点y坐标与线段相同 + p.x <= Math.max(start.x, end.x) && // 目标点x坐标不超过线段x坐标最大值 + p.x >= Math.min(start.x, end.x)) // 目标点x坐标不小于线段x坐标最小值 + ); +}; +/** +/** + * 生成带圆角转角的SVG路径 + * + * 该函数用于在图形编辑器中生成连接两点之间的边线路径。路径具有以下特点: + * 1. 两个控制点之间为直线段 + * 2. 在转折点处生成圆角过渡 + * 3. 支持垂直和水平方向的转角 + * + * @param points 控制点数组,包含边的起点、终点和中间的转折点 + * - 至少需要2个点(起点和终点) + * - 点的顺序应从输入端点开始到输出端点结束 + * @param radius 转角处的圆角半径 + * @returns 返回SVG路径字符串 + * @throws 当points数组长度小于2时抛出错误 + */ +export function getPathWithRoundCorners( + points: ControlPoint[], + radius: number +): string { + if (points.length < 2) { + throw new Error("At least 2 points are required."); + } + + /** + * 计算两条线段交点处的圆角路径 + * @param center 转折点坐标 + * @param p1 前一个点的坐标 + * @param p2 后一个点的坐标 + * @param radius 圆角半径 + * @returns SVG路径命令字符串 + */ + function getRoundCorner( + center: ControlPoint, + p1: ControlPoint, + p2: ControlPoint, + radius: number + ) { + const { x, y } = center; + + // 如果两条线段不垂直,则直接返回直线路径 + if (!areLinesPerpendicular(p1, center, center, p2)) { + return `L ${x} ${y}`; + } + + // 计算实际可用的圆角半径,取三个值中的最小值: + // 1. 与前一个点的距离的一半 + // 2. 与后一个点的距离的一半 + // 3. 传入的目标半径 + const d1 = distance(center, p1); + const d2 = distance(center, p2); + radius = Math.min(d1 / 2, d2 / 2, radius); + + // 判断第一条线段是否为水平线 + const isHorizontal = p1.y === y; + + // 根据点的相对位置确定圆角绘制方向 + const xDir = isHorizontal ? (p1.x < p2.x ? -1 : 1) : p1.x < p2.x ? 1 : -1; + const yDir = isHorizontal ? (p1.y < p2.y ? 1 : -1) : p1.y < p2.y ? -1 : 1; + + // 根据线段方向生成不同的圆角路径 + if (isHorizontal) { + return `L ${x + radius * xDir},${y}Q ${x},${y} ${x},${y + radius * yDir}`; + } + return `L ${x},${y + radius * yDir}Q ${x},${y} ${x + radius * xDir},${y}`; + } + + // 构建完整的SVG路径 + const path: string[] = []; + for (let i = 0; i < points.length; i++) { + if (i === 0) { + // 起点使用移动命令M + path.push(`M ${points[i].x} ${points[i].y}`); + } else if (i === points.length - 1) { + // 终点使用直线命令L + path.push(`L ${points[i].x} ${points[i].y}`); + } else { + // 中间点使用圆角转角 + path.push( + getRoundCorner(points[i], points[i - 1], points[i + 1], radius) + ); + } + } + + // 将所有路径命令连接成完整的路径字符串 + return path.join(" "); +} +/** + * 获取折线中最长的线段 + * @param points 控制点数组,每个点包含x和y坐标 + * @returns 返回最长线段的起点和终点坐标 + * + * 实现原理: + * 1. 初始化第一条线段为最长线段 + * 2. 遍历所有相邻点对,计算线段长度 + * 3. 如果找到更长的线段,则更新最长线段记录 + * 4. 返回最长线段的两个端点 + */ +export function getLongestLine( + points: ControlPoint[] +): [ControlPoint, ControlPoint] { + let longestLine: [ControlPoint, ControlPoint] = [points[0], points[1]]; + let longestDistance = distance(...longestLine); + for (let i = 1; i < points.length - 1; i++) { + const _distance = distance(points[i], points[i + 1]); + if (_distance > longestDistance) { + longestDistance = _distance; + longestLine = [points[i], points[i + 1]]; + } + } + return longestLine; +} + +/** + * 计算折线上标签的位置 + * @param points 控制点数组 + * @param minGap 最小间隔距离,默认为20 + * @returns 标签的坐标位置 + * + * 计算逻辑: + * 1. 如果折线点数为偶数: + * - 取中间两个点 + * - 如果这两点间距大于最小间隔,返回它们的中点 + * 2. 如果折线点数为奇数或中间段太短: + * - 找出最长的线段 + * - 返回最长线段的中点作为标签位置 + */ +export function getLabelPosition( + points: ControlPoint[], + minGap = 20 +): XYPosition { + if (points.length % 2 === 0) { + const middleP1 = points[points.length / 2 - 1]; + const middleP2 = points[points.length / 2]; + if (distance(middleP1, middleP2) > minGap) { + return getLineCenter(middleP1, middleP2); + } + } + const [start, end] = getLongestLine(points); + return { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + }; +} + +/** + * 判断两条线段是否垂直 + * @param p1,p2 第一条线段的起点和终点 + * @param p3,p4 第二条线段的起点和终点 + * @returns 如果两线段垂直则返回true + * + * 判断依据: + * - 假设线段要么水平要么垂直 + * - 当一条线段水平(y相等)而另一条垂直(x相等)时,两线段垂直 + */ +export function areLinesPerpendicular( + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + p4: ControlPoint +): boolean { + return (p1.x === p2.x && p3.y === p4.y) || (p1.y === p2.y && p3.x === p4.x); +} + +/** + * 判断两条线段是否平行 + * @param p1,p2 第一条线段的起点和终点 + * @param p3,p4 第二条线段的起点和终点 + * @returns 如果两线段平行则返回true + * + * 判断依据: + * - 假设线段要么水平要么垂直 + * - 当两条线段都是水平的(x相等)或都是垂直的(y相等)时,两线段平行 + */ +export function areLinesParallel( + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + p4: ControlPoint +) { + return (p1.x === p2.x && p3.x === p4.x) || (p1.y === p2.y && p3.y === p4.y); +} + +/** + * 判断两条线段是否同向 + * @param p1 第一条线段的起点 + * @param p2 第一条线段的终点 + * @param p3 第二条线段的起点 + * @param p4 第二条线段的终点 + * @returns boolean 如果两线段同向返回true,否则返回false + * + * 判断逻辑: + * 1. 对于水平线段(y坐标相等),判断x方向的变化是否同向 + * 2. 对于垂直线段(x坐标相等),判断y方向的变化是否同向 + */ +export function areLinesSameDirection( + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + p4: ControlPoint +) { + return ( + // 判断垂直线段是否同向 + (p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) > 0) || + // 判断水平线段是否同向 + (p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) > 0) + ); +} + +/** + * 判断两条线段是否反向 + * @param p1 第一条线段的起点 + * @param p2 第一条线段的终点 + * @param p3 第二条线段的起点 + * @param p4 第二条线段的终点 + * @returns boolean 如果两线段反向返回true,否则返回false + * + * 判断逻辑: + * 1. 对于水平线段(y坐标相等),判断x方向的变化是否反向 + * 2. 对于垂直线段(x坐标相等),判断y方向的变化是否反向 + */ +export function areLinesReverseDirection( + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + p4: ControlPoint +) { + return ( + // 判断垂直线段是否反向 + (p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) < 0) || + // 判断水平线段是否反向 + (p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) < 0) + ); +} + +/** + * 计算两条线段之间的夹角 + * @param p1 第一条线段的起点 + * @param p2 第一条线段的终点 + * @param p3 第二条线段的起点 + * @param p4 第二条线段的终点 + * @returns number 两线段之间的夹角(单位:度) + * + * 计算步骤: + * 1. 计算两个向量 + * 2. 计算向量的点积 + * 3. 计算向量的模长 + * 4. 使用反余弦函数计算弧度 + * 5. 将弧度转换为角度 + */ +export function getAngleBetweenLines( + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + p4: ControlPoint +) { + // 计算两条线段对应的向量 + const v1 = { x: p2.x - p1.x, y: p2.y - p1.y }; + const v2 = { x: p4.x - p3.x, y: p4.y - p3.y }; + + // 计算向量的点积 + const dotProduct = v1.x * v2.x + v1.y * v2.y; + + // 计算两个向量的模长 + const magnitude1 = Math.sqrt(v1.x ** 2 + v1.y ** 2); + const magnitude2 = Math.sqrt(v2.x ** 2 + v2.y ** 2); + + // 计算夹角的余弦值 + const cosine = dotProduct / (magnitude1 * magnitude2); + + // 使用反余弦函数计算弧度 + const angleInRadians = Math.acos(cosine); + + // 将弧度转换为角度并返回 + const angleInDegrees = (angleInRadians * 180) / Math.PI; + + return angleInDegrees; +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/index.ts b/apps/web/src/components/common/editor/graph/layout/edge/index.ts new file mode 100644 index 0000000..8e3f699 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/index.ts @@ -0,0 +1,72 @@ +import { EdgeLayout } from "../../types"; +import { getControlPoints, GetControlPointsParams } from "./algorithms"; +import { getLabelPosition, getPathWithRoundCorners } from "./edge"; +import { InternalNode, Node } from "@xyflow/react" +interface GetBasePathParams extends GetControlPointsParams { + borderRadius: number; +} + +export function getBasePath({ + id, + offset, + borderRadius, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: any) { + const sourceNode: InternalNode = + kReactFlow.instance!.getNode(source)!; + const targetNode: InternalNode = + kReactFlow.instance!.getNode(target)!; + return getPathWithPoints({ + offset, + borderRadius, + source: { + id: "source-" + id, + x: sourceX, + y: sourceY, + position: sourcePosition, + }, + target: { + id: "target-" + id, + x: targetX, + y: targetY, + position: targetPosition, + }, + sourceRect: { + ...(sourceNode.internals.positionAbsolute || sourceNode.position), + width: sourceNode.width!, + height: sourceNode.height!, + }, + targetRect: { + ...(targetNode.internals.positionAbsolute || targetNode.position), + width: targetNode.width!, + height: targetNode.height!, + }, + }); +} + +export function getPathWithPoints({ + source, + target, + sourceRect, + targetRect, + offset = 20, + borderRadius = 16, +}: GetBasePathParams): EdgeLayout { + const { points, inputPoints } = getControlPoints({ + source, + target, + offset, + sourceRect, + targetRect, + }); + const labelPosition = getLabelPosition(points); + const path = getPathWithRoundCorners(points, borderRadius); + return { path, points, inputPoints, labelPosition }; +} diff --git a/apps/web/src/components/common/editor/graph/layout/edge/point.ts b/apps/web/src/components/common/editor/graph/layout/edge/point.ts new file mode 100644 index 0000000..7c0fdf1 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/point.ts @@ -0,0 +1,623 @@ +import { Position } from "@xyflow/react"; +import { isHorizontalFromPosition } from "./edge"; +import { uuid } from "../../utils/uuid"; + +export interface ControlPoint { + id: string; + x: number; + y: number; +} + +export interface NodeRect { + x: number; // left + y: number; // top + width: number; + height: number; +} + +export interface RectSides { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface HandlePosition extends ControlPoint { + position: Position; +} + +export interface GetVerticesParams { + source: NodeRect; + target: NodeRect; + sourceOffset: ControlPoint; + targetOffset: ControlPoint; +} + +/** + * 计算两个节点之间的控制点位置 + * + * 该函数用于在图形编辑器中确定边的控制点,以实现更自然的边布局。 + * 主要应用于: + * 1. 节点之间连线的路径规划 + * 2. 边的弯曲程度控制 + * 3. 避免边与节点重叠 + * + * 实现原理: + * 1. 基于源节点和目标节点构建外部边界矩形 + * 2. 基于偏移点构建内部边界矩形 + * 3. 在两个矩形的边上生成候选控制点 + * 4. 过滤掉无效的控制点 + * + * @param {GetVerticesParams} params - 计算所需的参数 + * @param {Rect} params.source - 源节点的矩形区域,包含x、y、width、height + * @param {Rect} params.target - 目标节点的矩形区域 + * @param {Point} params.sourceOffset - 源节点上的连接点坐标 + * @param {Point} params.targetOffset - 目标节点上的连接点坐标 + * @returns {ControlPoint[]} 有效的控制点数组,每个点包含唯一ID和坐标 + */ +export const getCenterPoints = ({ + source, + target, + sourceOffset, + targetOffset, +}: GetVerticesParams): ControlPoint[] => { + // 特殊情况处理:当源点和目标点在同一直线上时,无法构成有效的控制区域 + if (sourceOffset.x === targetOffset.x || sourceOffset.y === targetOffset.y) { + return []; + } + + // 步骤1: 获取外部边界 + // 收集两个节点的所有顶点,用于构建外部最大矩形 + const vertices = [...getRectVertices(source), ...getRectVertices(target)]; + const outerSides = getSidesFromPoints(vertices); + + // 步骤2: 获取内部边界 + // 根据偏移点(实际连接点)计算内部矩形的四条边 + const { left, right, top, bottom } = getSidesFromPoints([ + sourceOffset, + targetOffset, + ]); + + // 步骤3: 计算中心参考线 + const centerX = (left + right) / 2; // 水平中心线 + const centerY = (top + bottom) / 2; // 垂直中心线 + + // 步骤4: 生成候选控制点 + // 在内外两个矩形的边上各生成4个控制点,共8个候选点 + const points = [ + { id: uuid(), x: centerX, y: top }, // 内矩形-上 + { id: uuid(), x: right, y: centerY }, // 内矩形-右 + { id: uuid(), x: centerX, y: bottom }, // 内矩形-下 + { id: uuid(), x: left, y: centerY }, // 内矩形-左 + { id: uuid(), x: centerX, y: outerSides.top }, // 外矩形-上 + { id: uuid(), x: outerSides.right, y: centerY }, // 外矩形-右 + { id: uuid(), x: centerX, y: outerSides.bottom },// 外矩形-下 + { id: uuid(), x: outerSides.left, y: centerY }, // 外矩形-左 + ]; + + // 步骤5: 过滤无效控制点 + // 移除落在源节点或目标节点内部的控制点,避免边穿过节点 + return points.filter((p) => { + return !isPointInRect(p, source) && !isPointInRect(p, target); + }); +}; + +/** + * 扩展矩形区域 + * @param rect 原始矩形区域 + * @param offset 扩展偏移量 + * @returns 扩展后的新矩形区域 + * + * 该函数将一个矩形区域向四周扩展指定的偏移量。 + * 扩展规则: + * 1. x和y坐标各向外偏移offset距离 + * 2. 宽度和高度各增加2*offset + */ +export const getExpandedRect = (rect: NodeRect, offset: number): NodeRect => { + return { + x: rect.x - offset, + y: rect.y - offset, + width: rect.width + 2 * offset, + height: rect.height + 2 * offset, + }; +}; + +/** + * 检测两个矩形是否重叠 + * @param rect1 第一个矩形 + * @param rect2 第二个矩形 + * @returns 布尔值,true表示重叠,false表示不重叠 + * + * 使用AABB(轴对齐包围盒)碰撞检测算法: + * 1. 计算x轴投影是否重叠 + * 2. 计算y轴投影是否重叠 + * 两个轴向都重叠则矩形重叠 + */ +export const isRectOverLapping = (rect1: NodeRect, rect2: NodeRect) => { + return ( + Math.abs(rect1.x - rect2.x) < (rect1.width + rect2.width) / 2 && + Math.abs(rect1.y - rect2.y) < (rect1.height + rect2.height) / 2 + ); +}; + +/** + * 判断点是否在矩形内 + * @param p 待检测的控制点 + * @param box 矩形区域 + * @returns 布尔值,true表示点在矩形内,false表示点在矩形外 + * + * 点在矩形内的条件: + * 1. x坐标在矩形左右边界之间 + * 2. y坐标在矩形上下边界之间 + */ +export const isPointInRect = (p: ControlPoint, box: NodeRect) => { + const sides = getRectSides(box); + return ( + p.x >= sides.left && + p.x <= sides.right && + p.y >= sides.top && + p.y <= sides.bottom + ); +}; + +/** + * 矩形顶点计算模块 + * 用于处理图形编辑器中矩形节点的顶点、边界等几何计算 + * 主要应用于连线路径规划和节点定位 + */ + +/** + * 根据矩形和外部顶点计算包围矩形的顶点坐标 + * @param box 原始矩形的位置和尺寸信息 + * @param vertex 外部控制点 + * @returns 包围矩形的四个顶点坐标 + * 算法思路: + * 1. 合并外部顶点和原矩形的顶点 + * 2. 计算所有点的边界范围 + * 3. 根据边界生成新的矩形顶点 + */ +export const getVerticesFromRectVertex = ( + box: NodeRect, + vertex: ControlPoint +): ControlPoint[] => { + const points = [vertex, ...getRectVertices(box)]; + const { top, right, bottom, left } = getSidesFromPoints(points); + return [ + { id: uuid(), x: left, y: top }, // 左上角顶点 + { id: uuid(), x: right, y: top }, // 右上角顶点 + { id: uuid(), x: right, y: bottom }, // 右下角顶点 + { id: uuid(), x: left, y: bottom }, // 左下角顶点 + ]; +}; + +/** + * 计算一组点的边界范围 + * @param points 控制点数组 + * @returns 返回边界的上下左右极值 + * 实现方式: + * - 使用数组map和Math.min/max计算坐标的最值 + */ +export const getSidesFromPoints = (points: ControlPoint[]) => { + const left = Math.min(...points.map((p) => p.x)); // 最左侧x坐标 + const right = Math.max(...points.map((p) => p.x)); // 最右侧x坐标 + const top = Math.min(...points.map((p) => p.y)); // 最上方y坐标 + const bottom = Math.max(...points.map((p) => p.y)); // 最下方y坐标 + return { top, right, bottom, left }; +}; + +/** + * 获取矩形的四条边界位置 + * @param box 矩形的位置和尺寸信息 + * @returns 矩形的上下左右边界坐标 + * 计算方式: + * - 左边界 = x坐标 + * - 右边界 = x + width + * - 上边界 = y坐标 + * - 下边界 = y + height + */ +export const getRectSides = (box: NodeRect): RectSides => { + const { x: left, y: top, width, height } = box; + const right = left + width; + const bottom = top + height; + return { top, right, bottom, left }; +}; + +/** + * 根据边界信息生成矩形的四个顶点 + * @param sides 矩形的上下左右边界坐标 + * @returns 返回四个顶点的坐标信息 + * 顶点顺序: 左上 -> 右上 -> 右下 -> 左下 + */ +export const getRectVerticesFromSides = ({ + top, + right, + bottom, + left, +}: RectSides): ControlPoint[] => { + return [ + { id: uuid(), x: left, y: top }, // 左上角顶点 + { id: uuid(), x: right, y: top }, // 右上角顶点 + { id: uuid(), x: right, y: bottom }, // 右下角顶点 + { id: uuid(), x: left, y: bottom }, // 左下角顶点 + ]; +}; + +/** + * 获取矩形的四个顶点坐标 + * @param box 矩形的位置和尺寸信息 + * @returns 返回矩形四个顶点的坐标 + * 实现流程: + * 1. 先计算矩形的边界 + * 2. 根据边界生成顶点 + */ +export const getRectVertices = (box: NodeRect) => { + const sides = getRectSides(box); + return getRectVerticesFromSides(sides); +}; +/** + * 合并多个矩形区域,返回一个能包含所有输入矩形的最小矩形 + * @param boxes 需要合并的矩形数组,每个矩形包含 x,y 坐标和宽高信息 + * @returns 合并后的最小包围矩形 + * + * 实现原理: + * 1. 找出所有矩形中最左边的 x 坐标(left)和最右边的 x 坐标(right) + * 2. 找出所有矩形中最上边的 y 坐标(top)和最下边的 y 坐标(bottom) + * 3. 用这四个边界值构造出新的矩形 + */ +export const mergeRects = (...boxes: NodeRect[]): NodeRect => { + // 计算所有矩形的最左边界 + const left = Math.min( + ...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[]) + ); + // 计算所有矩形的最右边界 + const right = Math.max( + ...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[]) + ); + // 计算所有矩形的最上边界 + const top = Math.min( + ...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[]) + ); + // 计算所有矩形的最下边界 + const bottom = Math.max( + ...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[]) + ); + + // 返回能包含所有输入矩形的最小矩形 + return { + x: left, // 左上角 x 坐标 + y: top, // 左上角 y 坐标 + width: right - left, // 宽度 = 最右边界 - 最左边界 + height: bottom - top, // 高度 = 最下边界 - 最上边界 + }; +}; + +/** + * 根据给定的位置和偏移量计算控制点坐标 + * @param box - 起始位置信息,包含x、y坐标和位置类型(上下左右) + * @param offset - 偏移距离 + * @returns 返回计算后的控制点对象,包含唯一id和新的x、y坐标 + */ +export const getOffsetPoint = ( + box: HandlePosition, + offset: number +): ControlPoint => { + // 根据不同的位置类型计算偏移后的坐标 + switch (box.position) { + case Position.Top: // 顶部位置,y坐标向上偏移 + return { + id: uuid(), + x: box.x, + y: box.y - offset, + }; + case Position.Bottom: // 底部位置,y坐标向下偏移 + return { id: uuid(), x: box.x, y: box.y + offset }; + case Position.Left: // 左侧位置,x坐标向左偏移 + return { id: uuid(), x: box.x - offset, y: box.y }; + case Position.Right: // 右侧位置,x坐标向右偏移 + return { id: uuid(), x: box.x + offset, y: box.y }; + } +}; + +/** + * 判断一个点是否在线段上 + * @param p - 待判断的点 + * @param p1 - 线段起点 + * @param p2 - 线段终点 + * @returns 如果点在线段上返回true,否则返回false + * + * 判断逻辑: + * 1. 点必须在线段所在的直线上(x坐标相等或y坐标相等) + * 2. 点的坐标必须在线段两端点坐标范围内 + */ +export const isInLine = ( + p: ControlPoint, + p1: ControlPoint, + p2: ControlPoint +) => { + // 获取x坐标的范围区间[min, max] + const xPoints = p1.x < p2.x ? [p1.x, p2.x] : [p2.x, p1.x]; + // 获取y坐标的范围区间[min, max] + const yPoints = p1.y < p2.y ? [p1.y, p2.y] : [p2.y, p1.y]; + + return ( + // 垂直线段:三点x坐标相等,且待判断点的y坐标在范围内 + (p1.x === p.x && p.x === p2.x && p.y >= yPoints[0] && p.y <= yPoints[1]) || + // 水平线段:三点y坐标相等,且待判断点的x坐标在范围内 + (p1.y === p.y && p.y === p2.y && p.x >= xPoints[0] && p.x <= xPoints[1]) + ); +}; + +/** + * 判断一个点是否在直线上(不考虑线段端点限制) + * @param p - 待判断的点 + * @param p1 - 直线上的点1 + * @param p2 - 直线上的点2 + * @returns 如果点在直线上返回true,否则返回false + * + * 判断逻辑: + * 仅判断点是否与直线上的两点共线(x坐标相等或y坐标相等) + */ +export const isOnLine = ( + p: ControlPoint, + p1: ControlPoint, + p2: ControlPoint +) => { + return (p1.x === p.x && p.x === p2.x) || (p1.y === p.y && p.y === p2.y); +}; +export interface OptimizePointsParams { + edgePoints: ControlPoint[]; + source: HandlePosition; + target: HandlePosition; + sourceOffset: ControlPoint; + targetOffset: ControlPoint; +} + +/** + * 优化边的控制点 + * + * 主要功能: + * 1. 合并坐标相近的点 + * 2. 删除重复的坐标点 + * 3. 修正起点和终点的位置 + * + * @param p 包含边的起点、终点、偏移点和中间控制点等信息的参数对象 + * @returns 优化后的控制点信息,包含起点、终点、起点偏移、终点偏移和中间控制点 + */ +export const optimizeInputPoints = (p: OptimizePointsParams) => { + // 合并坐标相近的点,将所有点放入一个数组进行处理 + let edgePoints = mergeClosePoints([ + p.source, + p.sourceOffset, + ...p.edgePoints, + p.targetOffset, + p.target, + ]); + + // 从合并后的点中提取起点和终点 + const source = edgePoints.shift()!; + const target = edgePoints.pop()!; + const sourceOffset = edgePoints[0]; + const targetOffset = edgePoints[edgePoints.length - 1]; + + // 根据起点和终点的位置类型修正其坐标 + // 如果是水平方向,则保持x坐标不变;否则保持y坐标不变 + if (isHorizontalFromPosition(p.source.position)) { + source.x = p.source.x; + } else { + source.y = p.source.y; + } + if (isHorizontalFromPosition(p.target.position)) { + target.x = p.target.x; + } else { + target.y = p.target.y; + } + + // 移除重复的坐标点,并为每个点分配唯一ID + edgePoints = removeRepeatPoints(edgePoints).map((p, idx) => ({ + ...p, + id: `${idx + 1}`, + })); + + return { source, target, sourceOffset, targetOffset, edgePoints }; +}; + +/** + * 简化边的控制点 + * + * 主要功能: + * 1. 确保直线上只保留两个端点 + * 2. 移除位于直线内部的控制点 + * + * 实现原理: + * - 遍历所有中间点 + * - 判断每个点是否在其相邻两点形成的直线上 + * - 如果在直线上则移除该点 + * + * @param points 原始控制点数组 + * @returns 简化后的控制点数组 + */ +export function reducePoints(points: ControlPoint[]): ControlPoint[] { + const optimizedPoints = [points[0]]; + + // 遍历除首尾点外的所有点 + for (let i = 1; i < points.length - 1; i++) { + // 判断当前点是否在前后两点形成的直线上 + const inSegment = isInLine(points[i], points[i - 1], points[i + 1]); + // 如果不在直线上,则保留该点 + if (!inSegment) { + optimizedPoints.push(points[i]); + } + } + + optimizedPoints.push(points[points.length - 1]); + return optimizedPoints; +} +/** + * 坐标点处理工具函数集合 + * 主要用于图形边缘控制点的坐标处理,包括合并、去重等操作 + */ + +/** + * 合并临近坐标点,同时将坐标值取整 + * @param points 控制点数组 + * @param threshold 合并阈值,默认为4个像素 + * @returns 处理后的控制点数组 + * + * 实现原理: + * 1. 分别记录x和y轴上的所有坐标值 + * 2. 对每个新坐标,在阈值范围内查找已存在的相近值 + * 3. 如果找到相近值则使用已存在值,否则添加新值 + */ +export function mergeClosePoints( + points: ControlPoint[], + threshold = 4 +): ControlPoint[] { + // 存储已处理的离散坐标值 + const positions = { x: [] as number[], y: [] as number[] }; + + /** + * 查找或添加坐标值 + * @param axis 坐标轴('x'|'y') + * @param v 待处理的坐标值 + * @returns 最终使用的坐标值 + */ + const findPosition = (axis: "x" | "y", v: number) => { + // 向下取整,确保坐标为整数 + v = Math.floor(v); + const ps = positions[axis]; + // 在阈值范围内查找已存在的相近值 + let p = ps.find((e) => Math.abs(v - e) < threshold); + // 如果没找到相近值,则添加新值 + if (p == null) { + p = v; + positions[axis].push(v); + } + return p; + }; + + // 处理每个控制点的坐标 + const finalPoints = points.map((point) => { + return { + ...point, + x: findPosition("x", point.x), + y: findPosition("y", point.y), + }; + }); + + return finalPoints; +} + +/** + * 判断两个控制点是否重合 + * @param p1 控制点1 + * @param p2 控制点2 + * @returns 是否重合 + */ +export function isEqualPoint(p1: ControlPoint, p2: ControlPoint) { + return p1.x === p2.x && p1.y === p2.y; +} + +/** + * 移除重复的控制点,但保留起点和终点 + * @param points 控制点数组 + * @returns 去重后的控制点数组 + * + * 实现思路: + * 1. 使用Set存储已处理的坐标字符串(格式:"x-y") + * 2. 保留最后一个点(终点) + * 3. 遍历时跳过重复坐标,但保留第一次出现的点 + */ +export function removeRepeatPoints(points: ControlPoint[]): ControlPoint[] { + // 先添加终点坐标,确保终点被保留 + const lastP = points[points.length - 1]; + const uniquePoints = new Set([`${lastP.x}-${lastP.y}`]); + const finalPoints: ControlPoint[] = []; + + points.forEach((p, idx) => { + // 处理终点 + if (idx === points.length - 1) { + return finalPoints.push(p); + } + // 使用坐标字符串作为唯一标识 + const key = `${p.x}-${p.y}`; + if (!uniquePoints.has(key)) { + uniquePoints.add(key); + finalPoints.push(p); + } + }); + return finalPoints; +} +/** + * 判断两条线段是否相交 + * @param p0 第一条线段的起点 + * @param p1 第一条线段的终点 + * @param p2 第二条线段的起点 + * @param p3 第二条线段的终点 + * @returns 如果两线段相交返回true,否则返回false + * + * 实现原理: + * 1. 使用向量叉积判断两线段是否平行 + * 2. 使用参数方程求解交点参数s和t + * 3. 判断参数是否在[0,1]区间内来确定是否相交 + */ +const isSegmentsIntersected = ( + p0: ControlPoint, + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint +): boolean => { + // 计算两条线段的方向向量 + const s1x = p1.x - p0.x; + const s1y = p1.y - p0.y; + const s2x = p3.x - p2.x; + const s2y = p3.y - p2.y; + + // 使用向量叉积判断两线段是否平行 + if (s1x * s2y - s1y * s2x === 0) { + // 平行线段必不相交 + return false; + } + + // 求解参数方程,获取交点参数s和t + const denominator = -s2x * s1y + s1x * s2y; + const s = (s1y * (p2.x - p0.x) - s1x * (p2.y - p0.y)) / denominator; + const t = (s2x * (p0.y - p2.y) - s2y * (p0.x - p2.x)) / denominator; + + // 当且仅当s和t都在[0,1]区间内时,两线段相交 + return s >= 0 && s <= 1 && t >= 0 && t <= 1; +}; + +/** + * 判断线段是否与矩形相交 + * @param p1 线段起点 + * @param p2 线段终点 + * @param box 矩形区域 + * @returns 如果线段与矩形有交点返回true,否则返回false + * + * 实现思路: + * 1. 首先处理特殊情况:矩形退化为点时必不相交 + * 2. 将矩形分解为四条边 + * 3. 判断线段是否与任意一条矩形边相交 + * 4. 只要与任意一边相交,则与矩形相交 + */ +export const isSegmentCrossingRect = ( + p1: ControlPoint, + p2: ControlPoint, + box: NodeRect +): boolean => { + // 处理特殊情况:矩形退化为点 + if (box.width === 0 && box.height === 0) { + return false; + } + + // 获取矩形的四个顶点 + const [topLeft, topRight, bottomRight, bottomLeft] = getRectVertices(box); + + // 判断线段是否与矩形的任意一条边相交 + return ( + isSegmentsIntersected(p1, p2, topLeft, topRight) || // 上边 + isSegmentsIntersected(p1, p2, topRight, bottomRight) || // 右边 + isSegmentsIntersected(p1, p2, bottomRight, bottomLeft) || // 下边 + isSegmentsIntersected(p1, p2, bottomLeft, topLeft) // 左边 + ); +}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/style.ts b/apps/web/src/components/common/editor/graph/layout/edge/style.ts new file mode 100644 index 0000000..409ec72 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/edge/style.ts @@ -0,0 +1,200 @@ +import { deepClone, lastOf } from "@/utils/base"; +import { Position, getBezierPath } from "reactflow"; + +import { getBasePath } from "."; +import { + kBaseMarkerColor, + kBaseMarkerColors, + kNoMarkerColor, + kYesMarkerColor, +} from "../../components/Edges/Marker"; +import { isEqual } from "../../utils/diff"; +import { EdgeLayout, ReactFlowEdgeWithData } from "../../data/types"; +import { kReactFlow } from "../../states/reactflow"; +import { getPathWithRoundCorners } from "./edge"; + +interface EdgeStyle { + color: string; + edgeType: "solid" | "dashed"; + pathType: "base" | "bezier"; +} + +/** + * Get the style of the connection line + * + * 1. When there are more than 3 edges connecting to both ends of the Node, use multiple colors to distinguish the edges. + * 2. When the connection line goes backward or connects to a hub Node, use dashed lines to distinguish the edges. + * 3. When the connection line goes from a hub to a Node, use bezier path. + */ +export const getEdgeStyles = (props: { + id: string; + isBackward: boolean; +}): EdgeStyle => { + const { id, isBackward } = props; + const idx = parseInt(lastOf(id.split("#")) ?? "0", 10); + if (isBackward) { + // Use dashed lines to distinguish the edges when the connection line goes backward or connects to a hub Node + return { color: kNoMarkerColor, edgeType: "dashed", pathType: "base" }; + } + const edge: ReactFlowEdgeWithData = kReactFlow.instance!.getEdge(id)!; + if (edge.data!.targetPort.edges > 2) { + // Use dashed bezier path when the connection line connects to a hub Node + return { + color: kYesMarkerColor, + edgeType: "dashed", + pathType: "bezier", + }; + } + if (edge.data!.sourcePort.edges > 2) { + // Use multiple colors to distinguish the edges when there are more than 3 edges connecting to both ends of the Node + return { + color: kBaseMarkerColors[idx % kBaseMarkerColors.length], + edgeType: "solid", + pathType: "base", + }; + } + return { color: kBaseMarkerColor, edgeType: "solid", pathType: "base" }; +}; + +interface ILayoutEdge { + id: string; + layout?: EdgeLayout; + offset: number; + borderRadius: number; + pathType: EdgeStyle["pathType"]; + source: string; + target: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: Position; + targetPosition: Position; +} + +export function layoutEdge({ + id, + layout, + offset, + borderRadius, + pathType, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: ILayoutEdge): EdgeLayout { + const relayoutDeps = [sourceX, sourceY, targetX, targetY]; + const needRelayout = !isEqual(relayoutDeps, layout?.deps?.relayoutDeps); + const reBuildPathDeps = layout?.points; + const needReBuildPath = !isEqual( + reBuildPathDeps, + layout?.deps?.reBuildPathDeps + ); + let newLayout = layout; + if (needRelayout) { + newLayout = _layoutEdge({ + id, + offset, + borderRadius, + pathType, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + } else if (needReBuildPath) { + newLayout = _layoutEdge({ + layout, + id, + offset, + borderRadius, + pathType, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + } + newLayout!.deps = deepClone({ relayoutDeps, reBuildPathDeps }); + return newLayout!; +} + +function _layoutEdge({ + id, + layout, + offset, + borderRadius, + pathType, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: ILayoutEdge): EdgeLayout { + const _pathType: EdgeStyle["pathType"] = pathType; + if (_pathType === "bezier") { + const [path, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + const points = [ + { + id: "source-" + id, + x: sourceX, + y: sourceY, + }, + { + id: "target-" + id, + x: targetX, + y: targetY, + }, + ]; + return { + path, + points, + inputPoints: points, + labelPosition: { + x: labelX, + y: labelY, + }, + }; + } + + if ((layout?.points?.length ?? 0) > 1) { + layout!.path = getPathWithRoundCorners(layout!.points, borderRadius); + return layout!; + } + + return getBasePath({ + id, + offset, + borderRadius, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); +} diff --git a/apps/web/src/components/common/editor/graph/layout/index.ts b/apps/web/src/components/common/editor/graph/layout/index.ts new file mode 100644 index 0000000..b109b95 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/index.ts @@ -0,0 +1,19 @@ +import { Node, Edge } from "@xyflow/react"; + +interface LayoutOptions { + nodes: Node[]; + edges: Edge[]; + levelSeparation?: number; + nodeSeparation?: number; +} + + + +export function getMindMapLayout(options: LayoutOptions) { + return { + nodes: options.nodes, + edges: options.edges, + + } + +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/metadata.ts b/apps/web/src/components/common/editor/graph/layout/metadata.ts new file mode 100644 index 0000000..992f95a --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/metadata.ts @@ -0,0 +1,148 @@ +import { MarkerType, Position, useInternalNode, Node, Edge } from "@xyflow/react"; +import { LayoutDirection, LayoutVisibility } from "./node"; + +/** + * 获取流程图中的根节点 + * @param nodes - 所有节点数组 + * @param edges - 所有边的数组 + * @returns 根节点数组(没有入边的节点) + */ +export const getRootNodes = (nodes: Node[], edges: Edge[]): Node[] => { + // 创建一个Set来存储所有有入边的节点ID + const nodesWithIncoming = new Set( + edges.map((edge) => edge.target) + ); + + // 过滤出没有入边的节点 + const rootNodes = nodes.filter( + (node) => !nodesWithIncoming.has(node.id) + ); + + return rootNodes; +}; +/** + * 获取节点尺寸信息的工具函数 + * @param node 需要获取尺寸的节点对象 + * @param defaultSize 默认尺寸配置,包含默认宽度(150px)和高度(36px) + * @returns 返回节点的尺寸信息对象,包含: + * - hasDimension: 是否已设置实际尺寸 + * - width: 节点实际宽度 + * - height: 节点实际高度 + * - widthWithDefault: 实际宽度或默认宽度 + * - heightWithDefault: 实际高度或默认高度 + */ +export const getNodeSize = ( + node: Node, + defaultSize = { width: 150, height: 36 } +) => { + // 获取节点的实际宽高 + const nodeWith = node?.width; + const nodeHeight = node?.height; + // 检查节点是否同时设置了宽度和高度 + const hasDimension = [nodeWith, nodeHeight].every((e) => e != null); + + // 返回包含完整尺寸信息的对象 + // 使用空值合并运算符(??)在实际尺寸未设置时使用默认值 + return { + hasDimension, + width: nodeWith, + height: nodeHeight, + widthWithDefault: nodeWith ?? defaultSize.width, + heightWithDefault: nodeHeight ?? defaultSize.height, + }; +}; + +export type IFixPosition = (pros: { + x: number; + y: number; + width: number; + height: number; +}) => { + x: number; + y: number; +}; +/** + * 节点布局计算函数 + * @description 根据给定的节点信息和布局参数,计算节点的最终布局属性 + * @param props 布局参数对象 + * @param props.node 需要布局的节点对象 + * @param props.position 节点的初始位置坐标 + * @param props.direction 布局方向,'horizontal'表示水平布局,'vertical'表示垂直布局 + * @param props.visibility 节点可见性,'visible'表示可见,其他值表示隐藏 + * @param props.fixPosition 可选的位置修正函数,用于调整最终位置 + * @returns 返回计算好布局属性的节点对象 + */ +export const getNodeLayouted = (props: { + node: Node; + position: { x: number; y: number }; + direction: LayoutDirection; + visibility: LayoutVisibility; + fixPosition?: IFixPosition; +}) => { + // 解构布局参数,设置位置修正函数的默认值 + const { + node, + position, + direction, + visibility, + fixPosition = (p) => ({ x: p.x, y: p.y }), + } = props; + + // 计算节点的显示状态和布局方向 + const hidden = visibility !== "visible"; + const isHorizontal = direction === "horizontal"; + + // 获取节点尺寸信息 + const { width, height, widthWithDefault, heightWithDefault } = + getNodeSize(node); + + // 根据布局方向设置节点的连接点位置 + node.targetPosition = isHorizontal ? Position.Left : Position.Top; + node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; + + // 返回带有完整布局属性的节点对象 + return { + ...node, + width, + height, + hidden, + position: fixPosition({ + ...position, + width: widthWithDefault, + height: heightWithDefault, + }), + style: { + ...node.style, + opacity: hidden ? 0 : 1, + }, + }; +}; + +/** + * 边布局计算函数 + * @description 根据给定的边信息和可见性参数,计算边的最终布局属性 + * @param props 布局参数对象 + * @param props.edge 需要布局的边对象 + * @param props.visibility 边的可见性,'visible'表示可见,其他值表示隐藏 + * @returns 返回计算好布局属性的边对象 + */ +export const getEdgeLayouted = (props: { + edge: Edge; + visibility: LayoutVisibility; +}) => { + const { edge, visibility } = props; + const hidden = visibility !== "visible"; + + // 返回带有完整布局属性的边对象 + return { + ...edge, + hidden, + markerEnd: { + type: MarkerType.ArrowClosed, // 设置箭头样式为闭合箭头 + }, + style: { + ...edge.style, + opacity: hidden ? 0 : 1, + }, + }; +}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-dag.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-dag.ts new file mode 100644 index 0000000..92ec51f --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-dag.ts @@ -0,0 +1,121 @@ +import { graphStratify, sugiyama } from "d3-dag"; +import { getIncomers, type Node } from "@xyflow/react"; +import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; +import { LayoutAlgorithm, LayoutAlgorithmProps } from ".."; +type NodeWithPosition = Node & { x: number; y: number }; + +// Since d3-dag layout algorithm does not support multiple root nodes, +// we attach the sub-workflows to the global rootNode. +const rootNode: NodeWithPosition = { + id: "#root", + x: 0, + y: 0, + position: { x: 0, y: 0 }, + data: {} as any, +}; + +const algorithms = { + "d3-dag": "d3-dag", + "ds-dag(s)": "ds-dag(s)", +}; + +export type D3DAGLayoutAlgorithms = "d3-dag" | "ds-dag(s)"; + +export const layoutD3DAG = async ( + props: LayoutAlgorithmProps & { algorithm?: D3DAGLayoutAlgorithms } +) => { + const { + nodes, + edges, + direction, + visibility, + spacing, + algorithm = "d3-dag", + } = props; + const isHorizontal = direction === "horizontal"; + + const initialNodes = [] as NodeWithPosition[]; + let maxNodeWidth = 0; + let maxNodeHeight = 0; + for (const node of nodes) { + const { widthWithDefault, heightWithDefault } = getNodeSize(node); + initialNodes.push({ + ...node, + ...node.position, + width: widthWithDefault, + height: heightWithDefault, + }); + maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault); + maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault); + } + + // Since d3-dag does not support horizontal layout, + // we swap the width and height of nodes and interchange x and y mappings based on the layout direction. + const nodeSize: any = isHorizontal + ? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x] + : [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y]; + + const getParentIds = (node: Node) => { + if (node.id === rootNode.id) { + return undefined; + } + // Node without input is the root node of sub-workflow, and we should connect it to the rootNode + const incomers = getIncomers(node, nodes, edges); + if (incomers.length < 1) { + return [rootNode.id]; + } + return algorithm === "d3-dag" + ? [incomers[0]?.id] + : incomers.map((e) => e.id); + }; + + const stratify = graphStratify(); + const dag = stratify( + [rootNode, ...initialNodes].map((node) => { + return { + id: node.id, + parentIds: getParentIds(node), + }; + }) + ); + + const layout = sugiyama().nodeSize(nodeSize); + layout(dag); + + const layoutNodes = new Map(); + for (const node of dag.nodes()) { + layoutNodes.set(node.data.id, node); + } + + return { + nodes: nodes.map((node) => { + const { x, y } = layoutNodes.get(node.id); + // Interchange x and y mappings based on the layout direction. + const position = isHorizontal ? { x: y, y: x } : { x, y }; + return getNodeLayouted({ + node, + position, + direction, + visibility, + fixPosition: ({ x, y, width, height }) => { + // This algorithm uses the center coordinate of the node as the reference point, + // which needs adjustment for ReactFlow's topLeft coordinate system. + return { + x: x - width / 2, + y: y - height / 2, + }; + }, + }); + }), + edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), + }; +}; + +export const kD3DAGAlgorithms: Record = Object.keys( + algorithms +).reduce((pre, algorithm) => { + pre[algorithm] = (props: any) => { + return layoutD3DAG({ ...props, algorithm }); + }; + return pre; +}, {} as any); diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-hierarchy.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-hierarchy.ts new file mode 100644 index 0000000..eae2813 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-hierarchy.ts @@ -0,0 +1,89 @@ +// Based on: https://github.com/flanksource/flanksource-ui/blob/75b35591d3bbc7d446fa326d0ca7536790f38d88/src/ui/Graphs/Layouts/algorithms/d3-hierarchy.ts + +import { stratify, tree, type HierarchyPointNode } from "d3-hierarchy"; +import {getIncomers, Node} from "@xyflow/react" +import { LayoutAlgorithm } from ".."; +import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; +type NodeWithPosition = Node & { x: number; y: number }; + +const layout = tree().separation(() => 1); + +// Since d3-hierarchy layout algorithm does not support multiple root nodes, +// we attach the sub-workflows to the global rootNode. +const rootNode: NodeWithPosition = { + id: "#root", + x: 0, + y: 0, + position: { x: 0, y: 0 }, + data: {} as any, +}; + +export const layoutD3Hierarchy: LayoutAlgorithm = async (props) => { + const { nodes, edges, direction, visibility, spacing } = props; + const isHorizontal = direction === "horizontal"; + + const initialNodes = [] as NodeWithPosition[]; + let maxNodeWidth = 0; + let maxNodeHeight = 0; + for (const node of nodes) { + const { widthWithDefault, heightWithDefault } = getNodeSize(node); + initialNodes.push({ + ...node, + ...node.position, + width: widthWithDefault, + height: heightWithDefault, + }); + maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault); + maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault); + } + + // Since d3-hierarchy does not support horizontal layout, + // we swap the width and height of nodes and interchange x and y mappings based on the layout direction. + const nodeSize: [number, number] = isHorizontal + ? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x] + : [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y]; + + layout.nodeSize(nodeSize); + + const getParentId = (node: Node) => { + if (node.id === rootNode.id) { + return undefined; + } + // Node without input is the root node of sub-workflow, and we should connect it to the rootNode + const incomers = getIncomers(node, nodes, edges); + return incomers[0]?.id || rootNode.id; + }; + + const hierarchy = stratify() + .id((d) => d.id) + .parentId(getParentId)([rootNode, ...initialNodes]); + + const root = layout(hierarchy); + const layoutNodes = new Map>(); + for (const node of root) { + layoutNodes.set(node.id!, node); + } + + return { + nodes: nodes.map((node) => { + const { x, y } = layoutNodes.get(node.id)!; + // Interchange x and y mappings based on the layout direction. + const position = isHorizontal ? { x: y, y: x } : { x, y }; + return getNodeLayouted({ + node, + position, + direction, + visibility, + fixPosition: ({ x, y, width, height }) => { + // This algorithm uses the center coordinate of the node as the reference point, + // which needs adjustment for ReactFlow's topLeft coordinate system. + return { + x: x - width / 2, + y: y - height / 2, + }; + }, + }); + }), + edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), + }; +}; diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/dagre-tree.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/dagre-tree.ts new file mode 100644 index 0000000..000b560 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/node/algorithms/dagre-tree.ts @@ -0,0 +1,122 @@ +import dagre from "@dagrejs/dagre"; +import { LayoutAlgorithm } from ".."; +import { getIncomers, Node } from "@xyflow/react"; +import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; +import { randomInt } from "../../../utils/base"; + +// 布局配置常量 +const LAYOUT_CONFIG = { + VIRTUAL_ROOT_ID: '#root', + VIRTUAL_NODE_SIZE: 1, + RANKER: 'tight-tree', +} as const; + +// 创建并配置 dagre 图实例 +const createDagreGraph = () => { + const graph = new dagre.graphlib.Graph(); + graph.setDefaultEdgeLabel(() => ({})); + return graph; +}; + +// 获取布局方向配置 +const getLayoutConfig = ( + direction: 'horizontal' | 'vertical', + spacing: { x: number, y: number }, + graph: dagre.graphlib.Graph +) => ({ + nodesep: direction === 'horizontal' ? spacing.y : spacing.x, + ranksep: direction === 'horizontal' ? spacing.x : spacing.y, + ranker: LAYOUT_CONFIG.RANKER, + rankdir: direction === 'horizontal' ? 'LR' : 'TB', + +}); + +// 查找根节点 +const findRootNodes = (nodes: Node[], edges: any[]): Node[] => + nodes.filter(node => getIncomers(node, nodes, edges).length < 1); + +// 计算节点边界 +const calculateBounds = (nodes: Node[], graph: dagre.graphlib.Graph) => { + const bounds = { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }; + + nodes.forEach(node => { + const pos = graph.node(node.id); + if (pos) { + bounds.minX = Math.min(bounds.minX, pos.x); + bounds.minY = Math.min(bounds.minY, pos.y); + bounds.maxX = Math.max(bounds.maxX, pos.x); + bounds.maxY = Math.max(bounds.maxY, pos.y); + } + }); + + return bounds; +}; + +export const layoutDagreTree: LayoutAlgorithm = async ({ + nodes, + edges, + direction, + visibility, + spacing +}) => { + const dagreGraph = createDagreGraph(); + + // 设置图的布局参数 + dagreGraph.setGraph(getLayoutConfig(direction, spacing, dagreGraph)); + + // 添加节点 + nodes.forEach((node) => { + const { widthWithDefault, heightWithDefault } = getNodeSize(node); + dagreGraph.setNode(node.id, { + width: widthWithDefault, + height: heightWithDefault, + order: randomInt(0, 10) + }); + }); + + // 添加边 + edges.forEach(edge => dagreGraph.setEdge(edge.source, edge.target)); + + // 处理多个子工作流的情况 + const rootNodes = findRootNodes(nodes, edges); + if (rootNodes.length > 1) { + dagreGraph.setNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, { + width: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE, + height: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE, + rank: -1 // 确保虚拟根节点排在最前面 + }); + rootNodes.forEach(node => + dagreGraph.setEdge(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, node.id) + ); + } + + // 执行布局 + dagre.layout(dagreGraph); + + // 移除虚拟根节点 + if (rootNodes.length > 1) { + dagreGraph.removeNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID); + } + + // 计算边界并返回布局结果 + const bounds = calculateBounds(nodes, dagreGraph); + + return { + nodes: nodes.map(node => getNodeLayouted({ + node, + position: dagreGraph.node(node.id), + direction, + visibility, + fixPosition: ({ x, y, width, height }) => ({ + x: x - width / 2 - bounds.minX, + y: y - height / 2 - bounds.minY, + }), + })), + edges: edges.map(edge => getEdgeLayouted({ edge, visibility })), + }; +}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/elk.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/elk.ts new file mode 100644 index 0000000..0238b71 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/node/algorithms/elk.ts @@ -0,0 +1,128 @@ +import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js"; +import { getIncomers,Node } from "@xyflow/react"; +import { LayoutAlgorithm, LayoutAlgorithmProps } from ".."; +import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; + +const algorithms = { + "elk-layered": "layered", + "elk-mr-tree": "mrtree", +}; + +const elk = new ELK({ algorithms: Object.values(algorithms) }); + +export type ELKLayoutAlgorithms = "elk-layered" | "elk-mr-tree"; + +export const layoutELK = async ( + props: LayoutAlgorithmProps & { algorithm?: ELKLayoutAlgorithms } +) => { + const { + nodes, + edges, + direction, + visibility, + spacing, + algorithm = "elk-mr-tree", + } = props; + const isHorizontal = direction === "horizontal"; + + const subWorkflowRootNodes: Node[] = []; + const layoutNodes = nodes.map((node) => { + const incomers = getIncomers(node, nodes, edges); + if (incomers.length < 1) { + // Node without input is the root node of sub-workflow + subWorkflowRootNodes.push(node); + } + const { widthWithDefault, heightWithDefault } = getNodeSize(node); + const sourcePorts = node.data.sourceHandles.map((id) => ({ + id, + properties: { + side: isHorizontal ? "EAST" : "SOUTH", + }, + })); + const targetPorts = node.data.targetHandles.map((id) => ({ + id, + properties: { + side: isHorizontal ? "WEST" : "NORTH", + }, + })); + return { + id: node.id, + width: widthWithDefault, + height: heightWithDefault, + ports: [...targetPorts, ...sourcePorts], + properties: { + "org.eclipse.elk.portConstraints": "FIXED_ORDER", + }, + }; + }); + + const layoutEdges = edges.map((edge) => { + return { + id: edge.id, + sources: [edge.sourceHandle || edge.source], + targets: [edge.targetHandle || edge.target], + }; + }); + + // Connect sub-workflows' root nodes to the rootNode + const rootNode: any = { id: "#root", width: 1, height: 1 }; + layoutNodes.push(rootNode); + for (const subWorkflowRootNode of subWorkflowRootNodes) { + layoutEdges.push({ + id: `${rootNode.id}-${subWorkflowRootNode.id}`, + sources: [rootNode.id], + targets: [subWorkflowRootNode.id], + }); + } + + const layouted = await elk + .layout({ + id: "@root", + children: layoutNodes, + edges: layoutEdges, + layoutOptions: { + // - https://www.eclipse.org/elk/reference/algorithms.html + "elk.algorithm": algorithms[algorithm], + "elk.direction": isHorizontal ? "RIGHT" : "DOWN", + // - https://www.eclipse.org/elk/reference/options.html + "elk.spacing.nodeNode": isHorizontal + ? spacing.y.toString() + : spacing.x.toString(), + "elk.layered.spacing.nodeNodeBetweenLayers": isHorizontal + ? spacing.x.toString() + : spacing.y.toString(), + }, + }) + .catch((e) => { + console.log("❌ ELK layout failed", e); + }) as ElkNode + + if (!layouted?.children) { + return; + } + + const layoutedNodePositions = layouted.children.reduce((pre, v) => { + pre[v.id] = { + x: v.x ?? 0, + y: v.y ?? 0, + }; + return pre; + }, {} as Record); + + return { + nodes: nodes.map((node) => { + const position = layoutedNodePositions[node.id]; + return getNodeLayouted({ node, position, direction, visibility }); + }), + edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), + }; +}; + +export const kElkAlgorithms: Record = Object.keys( + algorithms +).reduce((pre, algorithm) => { + pre[algorithm] = (props: any) => { + return layoutELK({ ...props, algorithm }); + }; + return pre; +}, {} as any); diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/origin.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/origin.ts new file mode 100644 index 0000000..25099ba --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/node/algorithms/origin.ts @@ -0,0 +1,20 @@ +import { LayoutAlgorithm } from ".."; +import { getEdgeLayouted, getNodeLayouted } from "../../metadata"; + +/** + * Positions all nodes at the origin (0,0) in the layout. + */ +export const layoutOrigin: LayoutAlgorithm = async (props) => { + const { nodes, edges, direction, visibility } = props; + return { + nodes: nodes.map((node) => { + return getNodeLayouted({ + node, + direction, + visibility, + position: { x: 0, y: 0 }, + }); + }), + edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), + }; +}; diff --git a/apps/web/src/components/common/editor/graph/layout/node/index.ts b/apps/web/src/components/common/editor/graph/layout/node/index.ts new file mode 100644 index 0000000..477b766 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/layout/node/index.ts @@ -0,0 +1,149 @@ +/** + * 图形布局模块 + * + * 该模块提供了一系列用于处理 ReactFlow 图形布局的工具和算法。 + * 支持多种布局算法,包括原始布局、树形布局、层次布局等。 + * 主要用于自动计算和调整图形中节点和边的位置。 + */ + +import { ReactFlowGraph } from "../../types"; +import { removeEmpty } from "../../utils/base"; +import { D3DAGLayoutAlgorithms, kD3DAGAlgorithms } from "./algorithms/d3-dag"; +import { layoutD3Hierarchy } from "./algorithms/d3-hierarchy"; +import { layoutDagreTree } from "./algorithms/dagre-tree"; +import { ELKLayoutAlgorithms, kElkAlgorithms } from "./algorithms/elk"; +import { layoutOrigin } from "./algorithms/origin"; + +/** + * 布局方向类型 + * vertical: 垂直布局 + * horizontal: 水平布局 + */ +export type LayoutDirection = "vertical" | "horizontal"; + +/** + * 布局可见性类型 + * visible: 可见 + * hidden: 隐藏 + */ +export type LayoutVisibility = "visible" | "hidden"; + +/** + * 布局间距配置接口 + * x: 水平间距 + * y: 垂直间距 + */ +export interface LayoutSpacing { + x: number; + y: number; +} + +/** + * ReactFlow 布局配置接口 + * 定义了布局所需的各项参数 + */ +export type ReactFlowLayoutConfig = { + algorithm: LayoutAlgorithms; // 使用的布局算法 + direction: LayoutDirection; // 布局方向 + spacing: LayoutSpacing; // 节点间距 + /** + * 布局可见性配置 + * 在首次布局时如果节点大小不可用,可能需要隐藏布局 + */ + visibility: LayoutVisibility; + /** + * 是否反转源节点手柄顺序 + */ + reverseSourceHandles: boolean; + autoCenterRoot: boolean +}; + +/** + * 布局算法所需的属性类型 + * 继承自 ReactFlowGraph 并包含布局配置(除算法外) + */ +export type LayoutAlgorithmProps = ReactFlowGraph & + Omit; + +/** + * 布局算法函数类型定义 + * 接收布局属性作为参数,返回布局后的图形数据 + */ +export type LayoutAlgorithm = ( + props: LayoutAlgorithmProps +) => Promise; + +/** + * 可用的布局算法映射表 + * 包含所有支持的布局算法实现 + */ +export const layoutAlgorithms: Record = { + origin: layoutOrigin, + "dagre-tree": layoutDagreTree, + "d3-hierarchy": layoutD3Hierarchy, + ...kElkAlgorithms, + ...kD3DAGAlgorithms, +}; + +/** + * 默认布局配置 + */ +export const defaultLayoutConfig: ReactFlowLayoutConfig = { + algorithm: "dagre-tree", // 默认使用 elk-mr-tree 算法 + direction: "horizontal", // 默认垂直布局 + visibility: "visible", // 默认可见 + spacing: { x: 120, y: 120 }, // 默认间距 + reverseSourceHandles: false, // 默认不反转源节点手柄 + autoCenterRoot: false +}; + +/** + * 支持的布局算法类型联合 + */ +export type LayoutAlgorithms = + | "origin" + | "dagre-tree" + | "d3-hierarchy" + | ELKLayoutAlgorithms + | D3DAGLayoutAlgorithms; + +/** + * ReactFlow 布局类型 + * 包含图形数据和可选的布局配置 + */ +export type ReactFlowLayout = ReactFlowGraph & Partial; + +/** + * 执行 ReactFlow 图形布局的主函数 + * + * @param options - 布局选项,包含图形数据和布局配置 + * @returns 返回布局后的图形数据 + * + * 函数流程: + * 1. 合并默认配置和用户配置 + * 2. 获取对应的布局算法 + * 3. 执行布局计算 + * 4. 如果布局失败,回退到原始布局 + */ +export const layoutReactFlow = async ( + options: ReactFlowLayout +): Promise => { + // 合并配置,移除空值 + const config = { ...defaultLayoutConfig, ...removeEmpty(options) }; + const { nodes = [], edges = [] } = config; + + // 获取并执行布局算法 + const layout = layoutAlgorithms[config.algorithm]; + let result = await layout({ ...config, nodes, edges }); + + // 布局失败时回退处理 + if (!result) { + result = await layoutReactFlow({ + ...config, + nodes, + edges, + algorithm: "origin", + }); + } + return result!; +}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx new file mode 100644 index 0000000..766cfe8 --- /dev/null +++ b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx @@ -0,0 +1,170 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Handle, Position, NodeProps, Node } from '@xyflow/react'; +import useGraphStore from '../store'; +import { shallow } from 'zustand/shallow'; +import { GraphState } from '../types'; + +export type GraphNode = Node<{ + label: string; + color?: string; + level?: number; +}, 'graph-node'>; + +const getLevelStyles = (level: number = 0) => { + const styles = { + 0: { + container: 'bg-[#2B4B6F] text-white', + handle: 'bg-[#2B4B6F]', + fontSize: 'text-lg' + }, + 1: { + container: 'bg-blue-300 text-white', + handle: 'bg-[#3A5F84]', + fontSize: 'text-base' + }, + 2: { + container: 'bg-gray-100', + handle: 'bg-[#496F96]', + fontSize: 'text-base' + } + }; + return styles[level as keyof typeof styles] +}; + + +const baseTextStyles = ` +text-center +break-words +whitespace-pre-wrap +`; +const handleStyles = ` + w-2.5 h-2.5 + border-2 border-white/80 + rounded-full + transition-colors + duration-200 + opacity-80 + hover:opacity-100 +`; +const selector = (store: GraphState) => ({ + updateNode: store.updateNode, +}); + +export const GraphNode = memo(({ id, selected, data, isConnectable }: NodeProps) => { + const { updateNode } = useGraphStore(selector, shallow); + const [isEditing, setIsEditing] = useState(false); + const levelStyles = getLevelStyles(data.level); + const [inputValue, setInputValue] = useState(data.label); + const [isComposing, setIsComposing] = useState(false); + const updateTextareaHeight = useCallback((element: HTMLTextAreaElement) => { + element.style.height = 'auto'; + element.style.height = `${element.scrollHeight}px`; + }, []); + const handleChange = useCallback((evt: React.ChangeEvent) => { + const newValue = evt.target.value; + setInputValue(newValue); + updateNode(id, { label: newValue }); + updateTextareaHeight(evt.target); + }, [updateNode, id, updateTextareaHeight]); + const handleKeyDown = useCallback((evt: React.KeyboardEvent) => { + if (!isEditing) { + if (/^[a-zA-Z0-9]$/.test(evt.key)) { + setIsEditing(true); + setInputValue(evt.key); // 将第一个字符添加到现有内容后 + updateNode(id, { label: evt.key }); + } + if (evt.key === ' ') { + setIsEditing(true); + setInputValue(data.label); // 将第一个字符添加到现有内容后 + updateNode(id, { label: data.label }); + } + evt.preventDefault(); // 阻止默认行为 + evt.stopPropagation(); // 阻止事件冒泡 + } else if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) { + setIsEditing(false); + evt.preventDefault(); + } + }, [isEditing, isComposing, data.label, id, updateNode]); + const handleDoubleClick = useCallback(() => { + setIsEditing(true); + }, []); + const handleBlur = useCallback(() => setIsEditing(false), []); + // 添加 ref 来获取父元素 + const containerRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (isEditing && textareaRef.current) { + updateTextareaHeight(textareaRef.current); + // 聚焦并将光标移到末尾 + textareaRef.current.focus(); + const length = textareaRef.current.value.length; + textareaRef.current.setSelectionRange(length, length); + } + }, [isEditing, updateTextareaHeight]); + + return ( +
+
+ {data.label} +
+