05 如何实现一个最简易的流程图库 - 最简模型

207 阅读13分钟

引言

书接上回,我们分别介绍了以 SVG、Canvas 为基础实现流程图的绘制,并分析对比了两种技术方案在实现交互时的具体方法及其差异点。本节我们就开始全盘分析,从绘制一个静态的固定内容的流程图,到基于数据渲染不同内容且有交互功能的流程图,需要实现哪些模块?数据该如何维护?节点类型又有哪些?线的生成逻辑是什么?需要有哪些基础的交互功能?事件又该怎么管理?

带着这些疑问,我们从理论中来,再到实践中去,通过拆解功能模块、设计并最后实现。在这篇文章中,我们的目标是实现一个简单但功能完整的流程图库,并定义项目的最小可行产品(MVP)。MVP 将包括一个画布(Canvas/SVG)、基础节点(Node)、连线(Edge)和最基础的交互功能。我们会从最简单的渲染功能开始,然后逐步增加交互功能,最终实现一个可以动态交互和拓展的流程图库。

核心概念

在构建流程图库时,有几个核心概念是至关重要的:

  • 节点(Nodes):代表流程图中的一个单元,例如一个步骤、任务或事件
  • 连线(Edges):连接两个节点的线条,标识流程中的依赖或顺序
  • 画布(Canvas/SVG):承载节点和连线的容器,负责整个流程图的渲染

基础功能实现

  1. 最简模型

模型关系图

如下图所示,我们定义画布类,用来绘制画布,作为容器,承载后续元素信息。接着定义节点和边的类,内部实现各自的渲染逻辑;最后在 FlowChart 类中管理节点和边的数据,并用 render 方法根据数据渲染图形。

image.png

框架实现

流程图类 - FlowChart:

class FlowChart {
  nodes: Node[] = [];
  edges: Edge[] = [];
  canvas: Canvas;

  constructor(canvas: Canvas) {
    this.canvas = canvas;
  }

  addNode(node: Node) {
    this.nodes.push(node);
  }

  addEdge(edge: Edge) {
    this.edges.push(edge);
  }

  render() {
    this.canvas.draw();
    this.nodes.forEach(node => node.render());
    this.edges.forEach(edge => edge.render());
  }
}

画布 - Canvas:

class Canvas {
  width: number;
  height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  draw() {
    // Implement drawing logic for the canvas
  }
}

节点 - Node:

class Node {
  id: string;
  x: number;
  y: number;

  constructor(id: string, x: number, y: number) {
    this.id = id;
    this.x = x;
    this.y = y;
  }

  render() {
    // Implement rendering logic for the node
  }
}

连线 - Edge:

class Edge {
  id: string;
  source: Node;
  target: Node;

  constructor(id: string, source: Node, target: Node) {
    this.id = id;
    this.source = source;
    this.target = target;
  }

  render() {
    // Implement rendering logic for the edge
  }
}

通过上面的代码,我们可以快速了解到一个流程图库运转所需的主要模块以及它们之间的调用关系。下面我们就针对各模块增加其核心逻辑

  1. 画布:实现基础画布的渲染

在流程图中,画布是承载所有元素的基础,决定了流程图的布局、节点和连线的渲染方式。在前面篇章,我们分析过了Canvas 和 SVG 两种渲染模式的差别。

还有一种比较符合前端常识的方案就是使用 HTML 实现节点的渲染,使用 SVG 进行边的渲染。根据以往的经验,我们发现该方案在以下这些点上优势是比较明显的:

1. 更灵活地样式和布局

  • HTML 节点: 可以利用 CSS 的强大功能进行样式定制,支持复杂的布局、动画和响应式设计,提升节点的视觉效果和用户体验
  • SVG 边: 专注于绘制连接线,保持边的渲染效率和清晰度。

2. 更好的内容管理和可维护性

  • 使用 HTML 可以更容易的管理节点内容,尤其是包含文本、图标或其它嵌入式元素时,代码结构更清晰,维护更简便;
  • SVG 对于复杂内容的管理可能导致代码冗长且不易维护

3. 更好的文本渲染和多语言支持

  • HTML 在处理多语言文本、字体样式和排版方面更为强大,确保文本的清晰度和一致性
  • SVG 对于复杂文本的支持相对有限,可能需要额外的调整以确保显示效果

4. 性能优化

  • HTML渲染节点 可以利用浏览器的 DOM 优化机制,特别是在节点数量较多时,提升渲染性能和响应速度
  • SVG 全部渲染 在处理大量节点和边时,可能导致性能瓶颈,影响整体流畅度

5. 更好的可访问性

  • HTML 元素 天生支持辅助技术(如屏幕阅读器),提升流程图的可访问性,确保不同用户群体都能顺利使用。
  • SVG 对于可访问性的支持相对有限,需要额外的处理和便签来实现

6. 便捷的动态更新和数据绑定

  • HTML 更容易与前端框架(如 React、Vue 等)集成,实现数据驱动的动态更新,提升开发效率
  • SVG 在动态更新和数据绑定方面,通常需要更多的手动操作和复杂的逻辑处理。

7. 更少的兼容性问题

  • SVG 节点中如果需要插入 HTML 元素,需要通过 foreignObject 元素进行嵌入,但该元素在不同浏览器中的支持完整程度不一致,会引起一些无法解决的 bug

综上,我们决定采用 HTML 渲染节点SVG 渲染边 的混合方式,能够充分发挥两者的优势,提升流程图库的灵活性、性能和用户体验,同时简化开发和维护工作。

那么我们在 Canvas 中就需要

export class Canvas {
  width: number;
  height: number;
  svg: SVGSVGElement; // 用于存储边 svg 元素的容器
  container: HTMLElement; // 用于存储画布、节点、边 svg 的容器

  constructor(element: HTMLElement, width: number, height: number) {
    this.width = width;
    this.height = height;
    
    this.container = element;
    this.svg = this.createSvgElement();
  }

  createSvgElement(): SVGSVGElement {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('width', this.width.toString());
    svg.setAttribute('height', this.height.toString());
    svg.style.position = 'absolute'; // 将 svg 元素与画布容器对其
    svg.style.top = '0';
    svg.style.left = '0';
    this.container.appendChild(svg);
    return svg;
  }

  clear() {
    while (this.container.firstChild) {
      this.container.removeChild(this.container.firstChild);
    }
  }

  addNode(element: HTMLElement) {
    this.container.appendChild(element);
  }

  addEdge(edgeElement: SVGElement) {
    this.svg.appendChild(edgeElement);
  }
  
  draw() {
    // draw something else, such as grid or background
  }
}
  1. 节点和连线:实现最简单的节点绘制和连线功能

我们定义一个 BaseNode 类来表示一个节点,节点可以时任何代表步骤或任务的图形单元。每个节点都有一个唯一的 id 和它在画布上的位置。连线(BaseEdge)则用于连接这些节点,标识流程之间的关系。

节点渲染:

export class BaseNode {
  id: string;
  x: number;
  y: number;
  label: string;
  element: HTMLDivElement;

  constructor(id: string, x: number, y: number, label: string) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.label = label;
    this.element = document.createElement('div');
    this.element.className = 'node';
    this.element.style.left = `${x}px`;
    this.element.style.top = `${y}px`;
    this.element.textContent = label;
    this.element.setAttribute('data-id', id);
  }

  render(container: HTMLElement) {
    container.appendChild(this.element);
  }
}

连线渲染:

import { Canvas } from "./Canvas";
import { BaseNode } from "./Node";

export class BaseEdge {
  id: string;
  source: BaseNode;
  target: BaseNode;
  element: SVGLineElement;

  constructor(id: string, source: BaseNode, target: BaseNode) {
    this.id = id;
    this.source = source;
    this.target = target;
    this.element = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    this.element.classList.add('edge');
    this.updatePosition();
  }

  render(svgContainer: SVGSVGElement) {
    svgContainer.appendChild(this.element);
  }
  
  updatePosition() {
    // 找到两个矩形的中点左边边的起点绘制边
    const sourceRect = this.source.element.getBoundingClientRect();
    const targetRect = this.target.element.getBoundingClientRect();
    const containerRect = this.source.element.parentElement!.getBoundingClientRect();

    const x1 = sourceRect.left + sourceRect.width / 2 - containerRect.left;
    const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
    const x2 = targetRect.left + targetRect.width / 2 - containerRect.left;
    const y2 = targetRect.top + targetRect.height / 2 - containerRect.top;

    this.element.setAttribute('x1', `${x1}`);
    this.element.setAttribute('y1', `${y1}`);
    this.element.setAttribute('x2', `${x2}`);
    this.element.setAttribute('y2', `${y2}`);
  }
}
  1. 完善 FlowChart 类的能力

import { Canvas } from "./Canvas";
import { BaseEdge } from "./Edge";
import { BaseNode } from "./Node";

export class FlowChart {
  nodes: BaseNode[] = [];
  edges: BaseEdge[] = [];
  canvas: Canvas;

  constructor(container: HTMLElement) {
    this.canvas = new Canvas(container, 800, 600); // 画布大小设置为 800x600
  }

  addNode(id: string, x: number, y: number, label: string): BaseNode {
    const node = new BaseNode(id, x, y, label);
    node.render(this.canvas.container);
    this.nodes.push(node);
    return node;
  }

  addEdge(id: string, sourceNode: BaseNode, targetNode: BaseNode) {
    const edge = new BaseEdge(id, sourceNode, targetNode);
    edge.render(this.canvas.svg);
    this.edges.push(edge);
  }

  render() {
    this.canvas.draw();
  }
}

示例代码

通过完成以上功能的开发,我们已经得到了一个简易的可执行库。我们写个简单的 demo 测试一下功能。

<!DOCTYPE html>
<html>
  <head lang="en">
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>next-flow-app</title>


    <style>
      body {
        font-family: "Helvetica Neue", Arial, sans-serif;
        color: #333;
        font-weight: 300;
      }
      #canvas {
        width: 800px;
        height: 600px;
        border: 1px solid #000;
        position: relative;
        background-color: #f9f9f9;
      }
      .node {
        width: 100px;
        height: 50px;
        background-color: #4caf50;
        color: white;
        position: absolute;
        text-align: center;
        line-height: 50px;
        border-radius: 5px;
        cursor: move;
      }
      .edge {
        stroke: #000;
        stroke-width: 2;
      }
    </style>
  </head>
  <body>
    <div id="canvas"></div>

    <script src="bundle.js"></script>
    <script>
      const { FlowChart, BaseNode, BaseEdge, Canvas } = NextFlow;

      const canvasElement = document.getElementById("canvas");
      const flowChart = new FlowChart(canvasElement);
      flowChart.render();

      // 新增元素:增加节点和边
      const node1 = flowChart.addNode("node1", 100, 100, "开始");
      const node2 = flowChart.addNode("node2", 300, 100, "步骤1");
      const node3 = flowChart.addNode("node3", 500, 100, "结束");

      flowChart.addEdge("edge1", node1, node2);
      flowChart.addEdge("edge2", node2, node3);
    </script>
  </body>
</html>

效果图: image.png

增加交互能力 - 节点拖拽

作为流程图库,最常用的功能便是拖拽、点击等交互。为了实现交互功能,我们需要为节点和连线添加事件处理。这包括鼠标点击、拖拽操作以及节点之间的连接。通过这些基本的交互,用户可以自定义流程图的布局和流程意义。

节点可拖动:

export class BaseNode {
  // ...之前的代码
 
  constructor(id: string, x: number, y: number, label: string) {
    // ...之前的代码
    this.initDrag(); // 增加
  }

  // ... 之前的代码
  initDrag() {
    let isDragging = false;
    let offsetX: number, offsetY: number;

    this.element.addEventListener('mousedown', (e) => {
      isDragging = true;
      offsetX = e.clientX - this.element.offsetLeft;
      offsetY = e.clientY - this.element.offsetTop;
      this.element.style.zIndex = '1000';
    });

    document.addEventListener('mousemove', (e) => {
      if (isDragging) {
        this.x = e.clientX - offsetX;
        this.y = e.clientY - offsetY;
        this.element.style.left = `${this.x}px`;
        this.element.style.top = `${this.y}px`;
        if (this.onMove) {
          this.onMove();
        }
      }
    });

    document.addEventListener('mouseup', () => {
      if (isDragging) {
        isDragging = false;
        this.element.style.zIndex = '';
      }
    });
  }

  setOnMove(callback: () => void) {
    this.onMove = callback;
  }
  
  // ... 之前的代码
}

效果展示:

增加上面的代码之后,我们的节点就可以拖动了。但同时我们又发现了另一个问题,节点移动时,和它关联的边并没有动,那接下来我们就处理一下这个问题。

2024-12-19 17.09.32.gif

节点关联的边同步更新

节点类中增加 updateEdges 方法,在节点拖动的同时调用该方法更新边的位置,以重新渲染关联的边;同时在初始化边时,将该边的信息存储到对应节点的 outgoingEdges 或 incomingEdges 数组中,方便后续同步更新

// 节点类方法
export class BaseNode {
  // ...之前的代码
  // 节点增加属性,用于记录当前节点关联的边
  incomingEdges: BaseEdge[] = [];
  outgoingEdges: BaseEdge[] = [];
 
  constructor(id: string, x: number, y: number, label: string) {
    // ...之前的代码
    this.initDrag(); // 增加
  }

  // ... 之前的代码
  initDrag() {
    // ...之前的代码

    document.addEventListener('mousemove', (e) => {
      if (isDragging) {
        this.x = e.clientX - offsetX;
        this.y = e.clientY - offsetY;
        this.element.style.left = `${this.x}px`;
        this.element.style.top = `${this.y}px`;
        
        this.updateEdges(); // 新增代码
        if (this.onMove) {
          this.onMove();
        }
      }
    });
    // ...
  }
  
  updateEdges() {
    this.incomingEdges.forEach(edge => edge.updatePosition());
    this.outgoingEdges.forEach(edge => edge.updatePosition());
  }
  
  // ... 之前的代码
}

// 连线类方法
export class BaseEdge {
  // ...原先的代码

  constructor(id: string, source: BaseNode, target: BaseNode) {
    // 原先的代码
    this.updatePosition();
    // 将当前边的实例保存到对应节点的 outgoingEdges 或 incomingEdges 数组中
    this.source.outgoingEdges.push(this);
    this.target.incomingEdges.push(this);
  }
  

  // ...原先的代码
}

效果展示

经过上面的调整后,我们就可以得到下面这样效果的功能了: 2024-12-19 17.41.59.gif

至此,我们也再更新一下我们项目的类关系图:

image.png

增加其它一些能力

节点上增加锚点

我们可以从上面的图上看出,目前这个连线是直接从节点中线连出来的,这样实在是不太优雅且不是大多数流程图库的实现方案,所以我们给节点加上锚点的概念,让线通过锚点连接,下面给出增加的代码逻辑:

  1. 节点增加锚点渲染
export class BaseNode {
  // ...
  anchorElements: HTMLDivElement[] = [];
  // ...

  render( HTMLElement) {
    // ...
    // 在 render 时创建锚点元素并更新
    this.createAnchors();
    this.anchorElements.forEach(anchor => container.appendChild(anchor));
    this.updateAnchors();
  }
  
  initDrag() {
    // ...
    document.addEventListener('mousemove', (e) => {
      if (isDragging) {
        // ...
        this.updateAnchors(); // 更新 Anchors 渲染
        this.updateEdges();
        // ...
      }
    })
    // ...
  }

  createAnchors() {
    const anchorPositions = [
      { x: 0, y: 0.5 }, // Left center
      { x: 1, y: 0.5 }, // Right center
      { x: 0.5, y: 0 }, // Top center
      { x: 0.5, y: 1 }  // Bottom center
    ];

    anchorPositions.forEach(pos => {
      const anchor = document.createElement('div');
      anchor.className = 'anchor';
      anchor.style.position = 'absolute';
      anchor.style.width = '10px';
      anchor.style.height = '10px';
      anchor.style.borderRadius = '10px';
      anchor.style.backgroundColor = 'red';
      this.anchorElements.push(anchor);
    });
  }

  updateAnchors() {
    const anchorPositions = [
      { x: 0, y: 0.5 }, // Left center
      { x: 1, y: 0.5 }, // Right center
      { x: 0.5, y: 0 }, // Top center
      { x: 0.5, y: 1 }  // Bottom center
    ];

    this.anchorElements.forEach((anchor, index) => {
      const pos = anchorPositions[index];
      anchor.style.left = `${this.x + pos.x * this.element.offsetWidth - 5}px`;
      anchor.style.top = `${this.y + pos.y * this.element.offsetHeight - 5}px`;
    });
  }

  getAnchorPosition(anchorIndex: number) {
    const anchorPositions = [
      { x: 0, y: 0.5 }, // Left center
      { x: 1, y: 0.5 }, // Right center
      { x: 0.5, y: 0 }, // Top center
      { x: 0.5, y: 1 }  // Bottom center
    ];
    const pos = anchorPositions[anchorIndex];
    return {
      x: this.x + pos.x * this.element.offsetWidth,
      y: this.y + pos.y * this.element.offsetHeight
    };
  }
  
  // ...其它现有代码
}

加上之后的效果图:

image.png

  1. 接着我们让线的起终点为节点的锚点
export class BaseEdge {
  // ...
  
  // 修改 updatePosition 中的代码逻辑
  updatePosition() {
    const sourceAnchorPos = this.source.getAnchorPosition(1); // Assuming right center for source
    const targetAnchorPos = this.target.getAnchorPosition(0); // Assuming left center for target

    const { x: x1, y: y1 } = sourceAnchorPos;
    const { x: x2, y: y2 } = targetAnchorPos;

    this.element.setAttribute('x1', x1.toString());
    this.element.setAttribute('y1', y1.toString());
    this.element.setAttribute('x2', x2.toString());
    this.element.setAttribute('y2', y2.toString());
  }

  // ...
}

加上之后的效果图: 2024-12-20 17.43.07.gif

增加节点和边的类型

为了使流程图更具实用性,我们可以增加不同类型的节点,如开始节点、结束节点、决策节点等。不同类型的节点可以有不同的样式和行为

实现如下:

// BaseNode 类
export class BaseNode {
  // ...
  type: string;
  // ...

  constructor(id: string, x: number, y: number, label: string, type = 'default') {
    // ...
    this.type = type; // 新增节点类型
    this.element = document.createElement('div');
    this.element.className = `node ${type}`;
    // ...
  }
  
  // ...
}

export class BaseEdge {
  // ...
  type: string; // 增加 type 属性
  // ...

  constructor(id: string, sourceNode: BaseNode, targetNode: BaseNode, type='default') {
    // ...
    this.type = type; // 新增边的类型
    this.element = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    this.element.classList.add('edge', type);
    // ...
  }
  
  // ...
}


// FlowChart 方法
export class FlowChart {
  // ... 其它现有代码
  
  // addNode 增加 type 参数
  addNode(id: string, x: number, y: number, label: string, type?: string): BaseNode {
    const node = new BaseNode(id, x, y, label, type);
    node.render(this.canvas.container);
    this.nodes.push(node);
    return node;
  }
  // addEdge 增加 type 参数
  addEdge(id: string, sourceNode: BaseNode, targetNode: BaseNode, type?: string) {
    const edge = new BaseEdge(id, sourceNode, targetNode, type);
    edge.render(this.canvas.svg);
    this.edges.push(edge);
    return edge;
  }
  // ... 其它现有代码
}

示例代码

JS:

const nodeStart = flowChart.addNode(
  "nodeStart",
  100,
  100,
  "开始",
  "start"
);
const nodeDecision = flowChart.addNode(
  "nodeDecision",
  300,
  100,
  "决策",
  "decision"
);
const nodeEnd = flowChart.addNode("nodeEnd", 500, 100, "结束", "end");

flowChart.addEdge("edgeStartDecision", nodeStart, nodeDecision, 'default');
flowChart.addEdge("edgeDecisionEnd", nodeDecision, nodeEnd, 'dashed');

CSS

.node.start {
  background-color: #2196f3;
  border-radius: 50%;
}

.node.end {
  background-color: #f44336;
  border-radius: 50%;
}

.node.decision {
  background-color: #ffc107;
  width: 100px;
  height: 100px;
  transform: rotate(45deg);
}

.node.decision div {
  transform: rotate(-45deg);
}

/* 增加不同类型边的样式 */
.edge.default {
  stroke: #000;
  stroke-width: 2;
  marker-end: url(#arrow);
}

.edge.dashed {
  stroke: #000;
  stroke-width: 2;
  stroke-dasharray: 5, 5;
  marker-end: url(#arrow);
}

实际效果

image.png

实现事件交互

为了使流程图库具有更高的交互性,我们需要实现一些基本的事件处理功能,如点击节点,拖拽节点、连接节点等

节点点击事件

export class BaseNode {
  // ...
  // 节点内置方法
  onMove?: EventCallbackType;
  onClick?: EventCallbackType;

  constructor(id: string, x: number, y: number, label: string, type = 'default') {
    // ... 其它现有代码
    this.initDrag();

    // 添加点击事件
    this.element.addEventListener('click', (e) => {
      e.stopPropagation(); // 防止事件冒泡到画布
      if (this.onClick) {
        this.onClick(this);
      }
    })
  }

  // ... 其它现有代码
}

FlowChart 类中的事件管理

export class FlowChart {
  // ... 其余现有代码

  selectedNode: BaseNode | null = null;
  connecting: boolean = false;

  constructor(container: HTMLElement) {
    this.canvas = new Canvas(container, 800, 600);

    // 绑定画布点击事件,用于取消选择
    this.canvas.container.addEventListener('click', () => {
      this.selectedNode = null;
      this.connecting = false;
      this.nodes.forEach(node => node.element.classList.remove('selected'));
    })
  }

  addNode(id: string, x: number, y: number, label: string, type?: string): BaseNode {
    const node = new BaseNode(id, x, y, label, type);
    node.render(this.canvas.container);
    this.nodes.push(node);

    // 设置节点点击事件
    node.setOnClick((clickedNode: BaseNode | undefined) => {
      if (clickedNode) {
        if (this.connecting && this.selectedNode && this.selectedNode !== clickedNode) {
          this.addEdge(`edge_${this.selectedNode.id}_${clickedNode.id}`, this.selectedNode, clickedNode, 'default');
          this.selectedNode.element.classList.remove('selected');
          this.selectedNode = null;
          this.connecting = false;
        } else {
          this.selectedNode = clickedNode;
          this.connecting = true;
          this.nodes.forEach(n => n.element.classList.remove('selected'));
          clickedNode.element.classList.add('selected');
        }
      }
    })

    return node;
  }
  
  // ...
  
  setOnMove(callback: EventCallbackType) {
    this.onMove = callback;
  }

  setOnClick(callback: EventCallbackType) {
    this.onClick = callback;
  }

  // ...
}

export type EventCallbackType = (node?: BaseNode) => void

使用实例

const flowChart = new FlowChart(canvasElement);

const node1 = flowChart.addNode('node1', 100, 100, '开始', 'start');
const node2 = flowChart.addNode('node2', 300, 100, '步骤1', 'default');
const node3 = flowChart.addNode('node3', 500, 100, '结束', 'end');

flowChart.render();

大家感兴趣可以自行体验一下效果。整体效果可以描述为:

  1. 点击一个节点后,该节点被选中(边框颜色变化)。
  2. 再次点击另一个节点时,自动在两个节点之间创建连线。
  3. 点击画布空白区域,取消当前选择。

动态数据渲染

最后,为了实现基于数据的动态渲染,我们需要设计一种数据结构来存储节点和边的信息,并根据这些数据动态生成流程图。

数据结构设计

const flowchartData = {
  nodes: [
    { id: "node1", x: 100, y: 100, label: "开始", type: "start" },
    { id: "node2", x: 300, y: 100, label: "步骤1", type: "default" },
    { id: "node3", x: 500, y: 100, label: "结束", type: "end" },
  ],
  edges: [
    { id: "edge1", source: "node1", target: "node2", type: "default" },
    { id: "edge2", source: "node2", target: "node3", type: "dashed" },
  ],
};

根据数据渲染流程图,代码修改如下

// Node.ts
export interface NodeData {
  id: string;
  x: number;
  y: number;
  label: string;
  type: string;
  style: CSSStyleDeclaration;
}

// Edge.ts
export interface EdgeData {
  id: string;
  source: string;
  target: string;
  type: string;
  sourceAnchorIndex: number;
  targetAnchorIndex: number;
}

export interface FlowChartData {
  nodes: NodeData[];
  edges: EdgeData[];
}

export class FlowChart {
  // ... 原先代码

  // 构造函数参数中增加 data 参数,用来渲染流程图
  constructor(container: HTMLElement, data: FlowChartData) {
    this.canvas = new Canvas(container, 800, 600);

    // 绑定画布点击事件,用于取消选择
    this.canvas.container.addEventListener('click', () => {
      this.selectedNode = null;
      this.connecting = false;
      this.nodes.forEach(node => node.element.classList.remove('selected'));
    })

    // 加载数据
    this.loadData(data);
  }

  // ... 原先其余代码

  getNodeById(id: string) {
    return this.nodes.find(node => node.id === id);
  }

  loadData(data: FlowChartData) {
    data.nodes.forEach(({ id, x, y, label, type }) => {
      const node = this.addNode(id, x, y, label, type);
    });

    data.edges.forEach(({ id, source, target, type }) => {
      const sourceNode = this.getNodeById(source);
      const targetNode = this.getNodeById(target);
      if (sourceNode && targetNode) {
          this.addEdge(id, sourceNode, targetNode, type);
      }
    })
  }

  // ... 原先其余代码
}

export interface FlowChartData {
  nodes: NodeData[];
  edges: EdgeData[];
}

使用示例

const flowChart = new FlowChart(canvasElement, flowchartData);
flowChart.render();

效果描述

  1. 根据 flowchartData 中的节点和边数据,动态生成对应的节点和连线
  2. 节点和边的位置、样式等信息均基于数据进行渲染,支持数据驱动的流程图生成。

总结

至此,本文详细的介绍了如何从理论到实践,实现一个最简易的流程图库。通过定义核心概念(节点、连线、画布)和实现基本功能(添加节点、拖拽节点、连接节点等),构建了一个基础但使用的流程图库模型。通过进一步的功能拓展,如增加节点类型、边类型、事件交互和动态数据渲染,使流程图库更加丰富和灵活。

接下来的章节,我们可以基于此模型进行更多的功能拓展和优化,如:

  • 数据视图模型分离:如何设计灵活的数据结构,确保流程图数据(节点、连线)与视图分离;
  • 节点高效管理:如何支持节点的增删改查,节点的样式和内容自定义
  • 动态连线管理:如何实现连线的动态调整、自动布局和路径计算等功能
  • 统一事件中心:缺少统一的事件管理系统,当前支持的交互太少,如何支持更多的交互操作(比如双击编辑、右键菜单等)
  • 优化图形渲染:如何在现有原始 DOM 操作的基础上,提高性能并增强视觉效果

通过不断迭代和优化,这个最简易的流程图库也可以发展成为一个功能丰富、用户友好的工具,广泛应用于软件开发、业务流程管理、教育培训等多个领域。