mcpField
D
dagre-react-flow

dagre-react-flow

github.com/existential-birds设计与 UI85243 次安装11 周前更新
代码仓库

关于 dagre-react-flow

dagre-react-flow 介绍如何将 Dagre 图布局用于 React Flow,适合需要自动排列有向图节点的 JavaScript 开发者。内容涵盖坐标转换、尺寸设置、布局方向及可复用的自动布局 Hook。

主要功能

  1. 01计算分层有向图布局
  2. 02支持四种布局方向
  3. 03处理 Dagre 与 React Flow 坐标差异
  4. 04设置节点尺寸与间距
  5. 05提供自动布局 Hook

适用场景

  1. 01排列组织图和决策树
  2. 02构建水平时间线或流程图
  3. 03调整节点间距与边路由

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/existential-birds/beagle --skill dagre-react-flow

安装后在 Claude Code 中输入/dagre-react-flow确认已加载。

附带文件reference.md

详细介绍

使用 React Flow 的 Dagre

Dagre 是一个用于布局有向图的 JavaScript 库。它会为分层/树形布局计算最优节点位置。React Flow 负责渲染,dagre 负责定位。

快速开始

pnpm add @dagrejs/dagre
import dagre from '@dagrejs/dagre';
import { Node, Edge } from '@xyflow/react';

const getLayoutedElements = (
  nodes: Node[],
  edges: Edge[],
  direction: 'TB' | 'LR' = 'TB'
) => {
  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: direction });
  g.setDefaultEdgeLabel(() => ({}));

  nodes.forEach((node) => {
    g.setNode(node.id, { width: 172, height: 36 });
  });

  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  const layoutedNodes = nodes.map((node) => {
    const pos = g.node(node.id);
    return {
      ...node,
      position: { x: pos.x - 86, y: pos.y - 18 }, // Center to top-left
    };
  });

  return { nodes: layoutedNodes, edges };
};

核心概念

坐标系差异

重要: Dagre 返回中心坐标;React Flow 使用左上角坐标。

// Dagre output: center of node
const dagrePos = g.node(nodeId); // { x: 100, y: 50 } = center

// React Flow expects: top-left corner
const rfPosition = {
  x: dagrePos.x - nodeWidth / 2,
  y: dagrePos.y - nodeHeight / 2,
};
节点尺寸

Dagre 需要显式指定尺寸。有以下三种方式:

1. 固定尺寸(最简单):

g.setNode(node.id, { width: 172, height: 36 });

2. 根据数据为每个节点指定尺寸:

g.setNode(node.id, {
  width: node.data.width ?? 172,
  height: node.data.height ?? 36,
});

3. 使用测量得到的尺寸(最准确):

// After React Flow measures nodes
g.setNode(node.id, {
  width: node.measured?.width ?? 172,
  height: node.measured?.height ?? 36,
});
布局方向
值方向用途
TB从上到下组织结构图、决策树
BT从下到上依赖关系图(依赖项位于底部)
LR从左到右时间线、水平流程
RL从右到左从右向左的布局
g.setGraph({ rankdir: 'LR' }); // Horizontal layout

硬性检查项

在确认布局正确之前,请按顺序执行以下检查(每一步都有客观的通过条件):

  1. 尺寸与坐标转换一致 — 对于每个节点 ID,传递给 g.setNode 的 width 和 height,必须与根据 g.node(id) 计算 position.x / position.y 时使用的数值相同(半宽/半高必须与 dagre 节点框一致)。
  2. 从中心坐标转换为左上角坐标 — position 应为 { x: centerX - width/2, y: centerY - height/2 },不能只使用原始的 g.node(id).x / .y。
  3. 更新 React Flow 状态 — 以编程方式布局后,传给 setNodes / setEdges 的必须是新数组实例(例如 [...layouted] 或 layouted.map(...)),不能原样使用之前的引用。
  4. 可选的合理性检查 — 如果布局后使用 fitView,应在节点提交后运行(例如在下一个 requestAnimationFrame 或 setTimeout(0) 中),而不是在调用 setNodes 的同一个同步周期中使用过期的测量结果。

完整实现

基本布局函数
import dagre from '@dagrejs/dagre';
import type { Node, Edge } from '@xyflow/react';

interface LayoutOptions {
  direction?: 'TB' | 'BT' | 'LR' | 'RL';
  nodeWidth?: number;
  nodeHeight?: number;
  nodesep?: number;  // Horizontal spacing
  ranksep?: number;  // Vertical spacing (between ranks)
}

export function getLayoutedElements(
  nodes: Node[],
  edges: Edge[],
  options: LayoutOptions = {}
): { nodes: Node[]; edges: Edge[] } {
  const {
    direction = 'TB',
    nodeWidth = 172,
    nodeHeight = 36,
    nodesep = 50,
    ranksep = 50,
  } = options;

  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: direction, nodesep, ranksep });
  g.setDefaultEdgeLabel(() => ({}));

  nodes.forEach((node) => {
    const width = node.measured?.width ?? nodeWidth;
    const height = node.measured?.height ?? nodeHeight;
    g.setNode(node.id, { width, height });
  });

  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  const layoutedNodes = nodes.map((node) => {
    const pos = g.node(node.id);
    const width = node.measured?.width ?? nodeWidth;
    const height = node.measured?.height ?? nodeHeight;

    return {
      ...node,
      position: {
        x: pos.x - width / 2,
        y: pos.y - height / 2,
      },
    };
  });

  return { nodes: layoutedNodes, edges };
}
React Flow 集成
import { useCallback } from 'react';
import {
  ReactFlow,
  useNodesState,
  useEdgesState,
  useReactFlow,
  ReactFlowProvider,
} from '@xyflow/react';
import { getLayoutedElements } from './layout';

const initialNodes = [
  { id: '1', data: { label: 'Start' }, position: { x: 0, y: 0 } },
  { id: '2', data: { label: 'Process' }, position: { x: 0, y: 0 } },
  { id: '3', data: { label: 'End' }, position: { x: 0, y: 0 } },
];

const initialEdges = [
  { id: 'e1-2', source: '1', target: '2' },
  { id: 'e2-3', source: '2', target: '3' },
];

// Apply initial layout
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
  initialNodes,
  initialEdges,
  { direction: 'TB' }
);

function Flow() {
  const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
  const { fitView } = useReactFlow();

  const onLayout = useCallback((direction: 'TB' | 'LR') => {
    const { nodes: newNodes, edges: newEdges } = getLayoutedElements(
      nodes,
      edges,
      { direction }
    );

    setNodes([...newNodes]);
    setEdges([...newEdges]);

    // Fit view after layout with animation
    window.requestAnimationFrame(() => {
      fitView({ duration: 300 });
    });
  }, [nodes, edges, setNodes, setEdges, fitView]);

  return (
    <div style={{ width: '100%', height: '100vh' }}>
      <div style={{ position: 'absolute', zIndex: 10, padding: 10 }}>
        <button onClick={() => onLayout('TB')}>Vertical</button>
        <button onClick={() => onLayout('LR')}>Horizontal</button>
      </div>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        fitView
      />
    </div>
  );
}

export default function App() {
  return (
    <ReactFlowProvider>
      <Flow />
    </ReactFlowProvider>
  );
}

useAutoLayout Hook

用于自动布局的可复用 Hook:

import { useCallback, useEffect, useRef } from 'react';
import {
  useReactFlow,
  useNodesInitialized,
  type Node,
  type Edge,
} from '@xyflow/react';
import dagre from '@dagrejs/dagre';

interface UseAutoLayoutOptions {
  direction?: 'TB' | 'BT' | 'LR' | 'RL';
  nodesep?: number;
  ranksep?: number;
}

export function useAutoLayout(options: UseAutoLayoutOptions = {}) {
  const { direction = 'TB', nodesep = 50, ranksep = 50 } = options;
  const { getNodes, getEdges, setNodes, fitView } = useReactFlow();
  const nodesInitialized = useNodesInitialized();
  const layoutApplied = useRef(false);

  const runLayout = useCallback(() => {
    const nodes = getNodes();
    const edges = getEdges();

    const g = new dagre.graphlib.Graph();
    g.setGraph({ rankdir: direction, nodesep, ranksep });
    g.setDefaultEdgeLabel(() => ({}));

    nodes.forEach((node) => {
      g.setNode(node.id, {
        width: node.measured?.width ?? 172,
        height: node.measured?.height ?? 36,
      });
    });

    edges.forEach((edge) => {
      g.setEdge(edge.source, edge.target);
    });

    dagre.layout(g);

    const layouted = nodes.map((node) => {
      const pos = g.node(node.id);
      const width = node.measured?.width ?? 172;
      const height = node.measured?.height ?? 36;

      return {
        ...node,
        position: { x: pos.x - width / 2, y: pos.y - height / 2 },
      };
    });

    setNodes(layouted);
    window.requestAnimationFrame(() => fitView({ duration: 200 }));
  }, [direction, nodesep, ranksep, getNodes, getEdges, setNodes, fitView]);

  // Auto-layout on initialization
  useEffect(() => {
    if (nodesInitialized && !layoutApplied.current) {
      runLayout();
      layoutApplied.current = true;
    }
  }, [nodesInitialized, runLayout]);

  return { runLayout };
}

用法:

function Flow() {
  const { runLayout } = useAutoLayout({ direction: 'LR', ranksep: 100 });

  return (
    <>
      <button onClick={runLayout}>Re-layout</button>
      <ReactFlow ... />
    </>
  );
}

边选项

使用权重和 minlen 控制边的路由:

edges.forEach((edge) => {
  g.setEdge(edge.source, edge.target, {
    weight: edge.data?.priority ?? 1,  // Higher = more direct path
    minlen: edge.data?.minRanks ?? 1,  // Minimum ranks between nodes
  });
});

weight:权重较高的边会优先获得更短、更直接的路径。

minlen:强制连接节点之间的排名至少相隔指定距离。

// Force 2 ranks between nodes
g.setEdge('a', 'b', { minlen: 2 });

常见模式

根据方向设置连接点位置

为水平和垂直布局调整连接点:

function CustomNode({ data }: NodeProps) {
  const isHorizontal = data.direction === 'LR' || data.direction === 'RL';

  return (
    <div>
      <Handle
        type="target"
        position={isHorizontal ? Position.Left : Position.Top}
      />
      <div>{data.label}</div>
      <Handle
        type="source"
        position={isHorizontal ? Position.Right : Position.Bottom}
      />
    </div>
  );
}
布局过渡动画

使用 CSS 过渡平滑地改变位置:

.react-flow__node {
  transition: transform 300ms ease-out;
}

如需了解如何以编程方式实现动画,请参阅 reference.md。

带节点组的布局

将组节点排除在 dagre 布局之外:

const layoutWithGroups = (nodes: Node[], edges: Edge[]) => {
  // Separate regular nodes from groups
  const regularNodes = nodes.filter((n) => n.type !== 'group');
  const groupNodes = nodes.filter((n) => n.type === 'group');

  // Layout only regular nodes
  const { nodes: layouted } = getLayoutedElements(regularNodes, edges);

  // Combine back
  return { nodes: [...groupNodes, ...layouted], edges };
};

故障排除

节点重叠

增大间距:

g.setGraph({
  rankdir: 'TB',
  nodesep: 100,  // Increase horizontal spacing
  ranksep: 100,  // Increase vertical spacing
});
布局未更新

确保使用新的数组引用:

// Wrong - same reference
setNodes(layoutedNodes);

// Correct - new reference
setNodes([...layoutedNodes]);
节点位置错误

检查坐标转换:

// Dagre returns center, React Flow needs top-left
position: {
  x: pos.x - width / 2,   // Not just pos.x
  y: pos.y - height / 2,  // Not just pos.y
}
大型图的性能问题
  • 在 Web Worker 中执行布局
  • 对布局调用进行防抖
  • 使用 useMemo 缓存布局函数
  • 仅重新布局发生变化的部分

配置参考

完整的 dagre 配置选项请参阅 reference.md。

评论

支持 Markdown