手写一个最简单版本的canvas库

从ECharts到手写G6:一个前端工程师的Canvas深度之旅

"当你在浏览器里看到无数canvas标签时,不是在看画布,是在看一个未被理解的宇宙。"

------ 一个曾经使用了各种canvas库,面对canvas标签调试中挣扎过的前端,终于摸清了Canvas的轴承


🌟 为什么我要手写一个G6?

这是我自己写的一个简单的g6实现,起因是我22年的时候帮我老婆完成一个招商项目的股权穿透图的经验,当时已经有了不少的canvas库使用经验,echart,d3,jsplumb,html2canvas,fabric.js,pixi.js等等,对canvas库,svg如何做2d的图形渲染有了些浅薄理解和见识,当浏览器开发者工具打开时,我盯着那一堆``标签,突然意识到: "这些库的底层逻辑,我好像从未真正理解过。" 让我萌生了研究吃透canvas的兴趣,于是研究g6和其他库的原理后,准备手写实现一个最简单的g6用作学习。

我用过D3.js的力导向布局、AntV G6的交互、jsPlumb的连线,甚至用过Fabric.js画过复杂图表。但每次遇到问题,都像在用"黑箱"------知道怎么用,却不知道为什么能用。

"不是我不会用库,是我害怕自己永远停留在'调API'的层面。"

于是,我决定:不写一个能用的项目,而是写一个'能看懂'的项目。目标很简单:用最简代码实现G6的核心能力------节点拖拽、边自动跟随、数据序列化。没有依赖,没有配置,只有Canvas的原生逻辑。


🔧 从零开始:我的四步拆解法

✅ 第一步:提炼核心,砍掉所有"糖衣"

G6的复杂性在于:布局算法、动画、状态管理、渲染优化...
我的最小化方案

javascript 复制代码
js
编辑
// 只保留最核心的三要素
class Graph {
  constructor() {
    this.nodes = new Map(); // 节点数据
    this.edges = new Map(); // 边数据
  }
}

为什么这样设计?

  • Map替代数组,O(1)时间查找节点(G6也是这样)
  • nodesedges唯一数据源,所有操作都基于它

💡 关键顿悟:图形库的本质不是"画图",是"管理节点和边的关系"


✅ 第二步:节点与边的"几何灵魂"

节点不能只是个坐标点,它需要:

  • 能被点击(contains方法)
  • 有样式(填充色、边框)
  • 文本可截断(避免溢出)
kotlin 复制代码
js
编辑
class Node {
  constructor(config) {
    this.id = config.id;
    this.x = config.x; // 中心点坐标
    this.y = config.y;
    this.label = config.label;
  }
  
  contains(px, py) {
    // 判断点击是否在节点范围内
    return px >= this.x - this.width/2 && ...;
  }
}

的难点在路径计算

你以为边是直线?不,G6用的是贝塞尔曲线!

javascript 复制代码
js
编辑
getEdgePath(sourceNode, targetNode) {
  const dx = targetNode.x - sourceNode.x;
  const dy = targetNode.y - sourceNode.y;
  // 计算贝塞尔曲线控制点(关键!)
  const controlOffset = Math.abs(dy) > Math.abs(dx) ? ... : ...;
  return `M ${startX} ${startY} C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${endX} ${endY}`;
}

为什么不用D3的line

因为D3的line为SVG设计 的,Canvas需要自己计算路径。这才是图形库的底层差异


✅ 第三步:渲染器------Canvas的"大脑"

Renderer是核心,它把数据变成画面:

kotlin 复制代码
js
编辑
class Renderer {
  render() {
    this.ctx.save();
    this.ctx.translate(this.offsetX, this.offsetY); // 平移
    this.ctx.scale(this.scale, this.scale);        // 缩放
    
    this.renderNodes(); // 画节点
    this.renderEdges(); // 画边
    
    this.ctx.restore(); // 恢复状态
  }
}

关键技巧

  • ctx.save()/ctx.restore()隔离变换(缩放/平移),避免污染画布

  • screenToCanvas方法解决坐标转换问题:

    kotlin 复制代码
    js
    编辑
    screenToCanvas(screenX, screenY) {
      const rect = this.canvas.getBoundingClientRect();
      return {
        x: (screenX - rect.left - this.offsetX) / this.scale,
        y: (screenY - rect.top - this.offsetY) / this.scale
      };
    }

💡 血泪教训 :第一次实现时,节点拖拽会"跳帧",因为没处理缩放坐标!
解决方案 :所有坐标计算都必须在ctx.scale之后进行。


✅ 第四步:交互------让图"活"起来

拖拽不是简单移动,需要:

  1. 检测点击节点(findNodeAt
  2. 计算拖拽偏移量(dragOffsetX/Y
  3. 实时更新节点位置
ini 复制代码
js
编辑
onMouseDown(e) {
  const node = this.renderer.findNodeAt(e.clientX, e.clientY);
  if (node) {
    this.dragNode = node;
    // 关键:计算点击点与节点中心的偏移
    this.dragOffsetX = e.clientX - node.x;
    this.dragOffsetY = e.clientY - node.y;
  }
}

为什么用reverse遍历节点?

因为Canvas渲染是从后往前的(后画的节点在上层),所以点击检测也要从后往前找,确保点击最上层的节点。


⚡ 那些被踩过的坑

问题 错误写法 正确解法
边路径不更新 边的路径只在创建时计算 每次渲染都重新计算路径 (用getPath
节点文本溢出 直接写长字符串 ctx.measureText动态截断(truncateText
导出图片模糊 canvas.toDataURL toBlob并设置分辨率dpr = window.devicePixelRatio
拖拽卡顿 每次拖拽重绘整个画布 只更新被拖拽的节点(但我的最小版没做优化,因为目标是清晰)

最痛的教训getArrowPoints方法,我写了3小时才让箭头对准终点。
关键 :箭头的坐标要基于节点中心,不是节点左上角!


💡 为什么这个"最小版"比G6更有价值?

  1. 理解了"状态驱动渲染"

    G6的graph.changeData()本质就是更新nodesedges,然后调render()
    我亲手实现了它,才明白"数据驱动视图"不是口号。

  2. 看清了库的"糖衣"

    G6的layoutanimationinteraction都是在核心数据模型上叠加的
    没有核心,这些"糖"都是浮云。

  3. 找回了对Canvas的热爱

    以前用ECharts,像在用"画笔";现在手写,像在用代码指挥画布

    "Canvas不是画布,是你的编程战场。"


🌈 给同样迷茫的前端伙伴

如果你也在用库(D3/ECharts/G6),试试做这件事:

  1. 删掉所有库,只保留``
  2. 只实现一个功能:画一个带拖拽的节点
  3. 每次改一点:先能拖,再加边,再加保存

"不是你不够强,是库让你忘了自己能强。"

------ 这个项目我写了3天,但理解的深度,远超之前3年用库的总和

全部代码如下:

js 复制代码
  
    
    
    手写一个canvas库
    
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          "Helvetica Neue", Arial, sans-serif;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        overflow: hidden;
        height: 100vh;
        display: flex;
        flex-direction: column;
      }

      .header {
        background: rgba(255, 255, 255, 0.95);
        backdrop-filter: blur(10px);
        padding: 20px 30px;
        box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
        display: flex;
        justify-content: space-between;
        align-items: center;
        z-index: 10;
      }

      .header h1 {
        font-size: 24px;
        font-weight: 600;
        color: #2d3748;
        display: flex;
        align-items: center;
        gap: 12px;
      }

      .header h1::before {
        content: "📊";
        font-size: 28px;
      }

      .controls {
        display: flex;
        gap: 12px;
      }

      button {
        padding: 10px 20px;
        border: none;
        border-radius: 8px;
        font-size: 14px;
        font-weight: 500;
        cursor: pointer;
        transition: all 0.3s ease;
        display: flex;
        align-items: center;
        gap: 6px;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
      }

      button:hover {
        transform: translateY(-2px);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      }

      button:active {
        transform: translateY(0);
      }

      #save-btn {
        background: #48bb78;
        color: white;
      }

      #save-btn:hover {
        background: #38a169;
      }

      #load-btn {
        background: #4299e1;
        color: white;
      }

      #load-btn:hover {
        background: #3182ce;
      }

      #export-btn {
        background: #ed8936;
        color: white;
      }

      #export-btn:hover {
        background: #dd6b20;
      }

      #clear-btn {
        background: #f56565;
        color: white;
      }

      #clear-btn:hover {
        background: #e53e3e;
      }

      .canvas-container {
        flex: 1;
        position: relative;
        display: flex;
        justify-content: center;
        align-items: center;
        padding: 20px;
      }

      #graph-canvas {
        background: white;
        border-radius: 12px;
        box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
        width: 100%;
        height: 100%;
        cursor: default;
      }

      .info-panel {
        position: absolute;
        bottom: 30px;
        left: 30px;
        background: rgba(255, 255, 255, 0.95);
        backdrop-filter: blur(10px);
        padding: 16px 20px;
        border-radius: 10px;
        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
        font-size: 13px;
        color: #4a5568;
        line-height: 1.6;
      }

      .info-panel h3 {
        font-size: 14px;
        font-weight: 600;
        color: #2d3748;
        margin-bottom: 8px;
      }

      .info-panel ul {
        list-style: none;
        padding-left: 0;
      }

      .info-panel li {
        padding: 4px 0;
      }

      .info-panel li::before {
        content: "•";
        color: #667eea;
        font-weight: bold;
        display: inline-block;
        width: 1em;
        margin-left: -1em;
        padding-right: 0.5em;
      }

      @media (max-width: 768px) {
        .header {
          flex-direction: column;
          gap: 16px;
        }

        .controls {
          width: 100%;
          justify-content: center;
          flex-wrap: wrap;
        }

        .info-panel {
          position: relative;
          bottom: auto;
          left: auto;
          margin: 10px;
        }
      }
    
  
  
    <div class="header">
      <h1>简易图编辑器(单文件版)</h1>
      <div class="controls">
        💾 保存
        📂 加载
        📸 导出图片
        🗑️ 清空
      </div>
    </div>

    <div class="canvas-container">
      

      <div class="info-panel">
        <h3>操作说明</h3>
        <ul>
          <li>拖拽节点可移动位置</li>
          <li>边会自动跟随节点位置更新</li>
          <li>点击&#34;保存&#34;可导出JSON文件</li>
          <li>点击&#34;加载&#34;可导入JSON文件</li>
        </ul>
      </div>
    </div>

    
      class Node {
        constructor(config) {
          this.id = config.id;
          this.x = config.x;
          this.y = config.y;
          this.label = config.label;
          this.width = config.width || 120;
          this.height = config.height || 50;
          this.style = {
            fill: config.style?.fill || &#34;#5B8FF9&#34;,
            stroke: config.style?.stroke || &#34;#3366CC&#34;,
            strokeWidth: config.style?.strokeWidth || 2,
            textColor: config.style?.textColor || &#34;#FFFFFF&#34;,
          };
        }

        contains(px, py) {
          return (
            px >= this.x - this.width / 2 &&
            px <= this.x + this.width / 2 &&
            py >= this.y - this.height / 2 &&
            py <= this.y + this.height / 2
          );
        }

        moveTo(x, y) {
          this.x = x;
          this.y = y;
        }

        toJSON() {
          return {
            id: this.id,
            x: this.x,
            y: this.y,
            label: this.label,
            width: this.width,
            height: this.height,
            style: { ...this.style },
          };
        }
      }

      class Edge {
        constructor(config) {
          this.id = config.id;
          this.source = config.source;
          this.target = config.target;
          this.style = {
            stroke: config.style?.stroke || &#34;#999999&#34;,
            strokeWidth: config.style?.strokeWidth || 2,
            lineType: config.style?.lineType || &#34;solid&#34;,
          };
        }

        getPath(sourceNode, targetNode) {
          const x1 = sourceNode.x;
          const y1 = sourceNode.y;
          const x2 = targetNode.x;
          const y2 = targetNode.y;

          const dx = x2 - x1;
          const dy = y2 - y1;
          const distance = Math.sqrt(dx * dx + dy * dy);

          if (distance === 0) {
            return `M ${x1} ${y1}`;
          }

          const offsetX1 = (dx / distance) * (sourceNode.height / 2);
          const offsetY1 = (dy / distance) * (sourceNode.height / 2);
          const offsetX2 = (dx / distance) * (targetNode.height / 2);
          const offsetY2 = (dy / distance) * (targetNode.height / 2);
          console.log(&#34;sourceNode&#34;, sourceNode.label);
          console.log(&#34;offsetX1Y1X2Y2&#34;, offsetX1, offsetY1, offsetX2, offsetY2);
          const startX = x1 + offsetX1;
          const startY = y1 + offsetY1;
          const endX = x2 - offsetX2;
          const endY = y2 - offsetY2;

          const controlOffset =
            Math.abs(dy) > Math.abs(dx)
              ? Math.abs(dx) * 0.3
              : Math.abs(dy) * 0.3;
          const cpx1 = startX;
          const cpy1 = startY + controlOffset;
          const cpx2 = endX;
          const cpy2 = endY - controlOffset;

          return `M ${startX} ${startY} C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${endX} ${endY}`;
        }

        getArrowPoints(sourceNode, targetNode) {
          const dx = targetNode.x - sourceNode.x;
          const dy = targetNode.y - sourceNode.y;
          const distance = Math.sqrt(dx * dx + dy * dy);

          if (distance === 0) return &#34;&#34;;

          const offsetX = (dx / distance) * (targetNode.height / 2);
          const offsetY = (dy / distance) * (targetNode.height / 2);
          const endX = targetNode.x - offsetX;
          const endY = targetNode.y - offsetY;

          const angle = Math.atan2(dy, dx);
          const arrowLength = 10;
          const arrowAngle = Math.PI / 6;

          const x1 = endX - arrowLength * Math.cos(angle - arrowAngle);
          const y1 = endY - arrowLength * Math.sin(angle - arrowAngle);
          const x2 = endX - arrowLength * Math.cos(angle + arrowAngle);
          const y2 = endY - arrowLength * Math.sin(angle + arrowAngle);

          return `${endX},${endY} ${x1},${y1} ${x2},${y2}`;
        }

        toJSON() {
          return {
            id: this.id,
            source: this.source,
            target: this.target,
            style: { ...this.style },
          };
        }
      }

      class Graph {
        constructor() {
          this.nodes = new Map();
          this.edges = new Map();
        }

        addNode(config) {
          const node = new Node(config);
          this.nodes.set(node.id, node);
          return node;
        }

        addEdge(config) {
          const edge = new Edge(config);
          this.edges.set(edge.id, edge);
          return edge;
        }

        getNode(id) {
          return this.nodes.get(id);
        }

        getEdge(id) {
          return this.edges.get(id);
        }

        clear() {
          this.nodes.clear();
          this.edges.clear();
        }

        toJSON() {
          const nodes = [];
          const edges = [];

          this.nodes.forEach((node) => {
            nodes.push(node.toJSON());
          });

          this.edges.forEach((edge) => {
            edges.push(edge.toJSON());
          });

          return { nodes, edges };
        }

        fromJSON(data) {
          this.clear();

          data.nodes.forEach((nodeConfig) => {
            this.addNode(nodeConfig);
          });

          data.edges.forEach((edgeConfig) => {
            this.addEdge(edgeConfig);
          });
        }
      }

      class Renderer {
        constructor(canvas, graph) {
          this.canvas = canvas;
          this.ctx = canvas.getContext(&#34;2d&#34;);
          this.graph = graph;
          this.scale = 1;
          this.offsetX = 0;
          this.offsetY = 0;

          this.resizeCanvas();
          window.addEventListener(&#34;resize&#34;, () => this.resizeCanvas());
        }

        resizeCanvas() {
          const dpr = window.devicePixelRatio || 1;
          const rect = this.canvas.getBoundingClientRect();

          this.canvas.width = rect.width * dpr;
          this.canvas.height = rect.height * dpr;

          this.ctx.scale(dpr, dpr);
          this.canvas.style.width = rect.width + &#34;px&#34;;
          this.canvas.style.height = rect.height + &#34;px&#34;;

          this.render();
        }

        clear() {
          const rect = this.canvas.getBoundingClientRect();
          this.ctx.clearRect(0, 0, rect.width, rect.height);
        }

        render() {
          this.clear();

          this.ctx.save();
          this.ctx.translate(this.offsetX, this.offsetY);
          this.ctx.scale(this.scale, this.scale);

          this.renderEdges();
          this.renderNodes();

          this.ctx.restore();
        }

        renderNodes() {
          this.graph.nodes.forEach((node) => {
            this.renderNode(node);
          });
        }

        renderNode(node) {
          const x = node.x - node.width / 2;
          const y = node.y - node.height / 2;

          this.ctx.fillStyle = node.style.fill;
          this.ctx.strokeStyle = node.style.stroke;
          this.ctx.lineWidth = node.style.strokeWidth;

          const radius = 8;
          this.ctx.beginPath();
          this.ctx.moveTo(x + radius, y);
          this.ctx.lineTo(x + node.width - radius, y);
          this.ctx.quadraticCurveTo(
            x + node.width,
            y,
            x + node.width,
            y + radius
          );
          this.ctx.lineTo(x + node.width, y + node.height - radius);
          this.ctx.quadraticCurveTo(
            x + node.width,
            y + node.height,
            x + node.width - radius,
            y + node.height
          );
          this.ctx.lineTo(x + radius, y + node.height);
          this.ctx.quadraticCurveTo(
            x,
            y + node.height,
            x,
            y + node.height - radius
          );
          this.ctx.lineTo(x, y + radius);
          this.ctx.quadraticCurveTo(x, y, x + radius, y);
          this.ctx.closePath();
          this.ctx.fill();
          this.ctx.stroke();

          this.ctx.fillStyle = node.style.textColor;
          this.ctx.font = &#34;14px Arial, sans-serif&#34;;
          this.ctx.textAlign = &#34;center&#34;;
          this.ctx.textBaseline = &#34;middle&#34;;

          const maxWidth = node.width - 20;
          const text = this.truncateText(node.label, maxWidth);
          this.ctx.fillText(text, node.x, node.y);
        }

        truncateText(text, maxWidth) {
          const measured = this.ctx.measureText(text);
          console.log(&#34;measureText&#34;, measured.width);
          if (measured.width <= maxWidth) {
            return text;
          }

          let truncated = text;
          while (truncated.length > 0) {
            truncated = truncated.slice(0, -1);
            const measuredTruncated = this.ctx.measureText(truncated + &#34;...&#34;);
            if (measuredTruncated.width <= maxWidth) {
              return truncated + &#34;...&#34;;
            }
          }
          return &#34;...&#34;;
        }

        renderEdges() {
          this.graph.edges.forEach((edge) => {
            const sourceNode = this.graph.getNode(edge.source);
            const targetNode = this.graph.getNode(edge.target);

            if (sourceNode && targetNode) {
              this.renderEdge(edge, sourceNode, targetNode);
            }
          });
        }

        renderEdge(edge, sourceNode, targetNode) {
          this.ctx.strokeStyle = edge.style.stroke;
          this.ctx.lineWidth = edge.style.strokeWidth;

          if (edge.style.lineType === &#34;dashed&#34;) {
            this.ctx.setLineDash([5, 5]);
          } else {
            this.ctx.setLineDash([]);
          }

          const path = new Path2D(edge.getPath(sourceNode, targetNode));
          this.ctx.stroke(path);

          const arrowPoints = edge.getArrowPoints(sourceNode, targetNode);
          if (arrowPoints) {
            this.ctx.fillStyle = edge.style.stroke;
            this.ctx.beginPath();
            const points = arrowPoints
              .split(&#34; &#34;)
              .map((p) => p.split(&#34;,&#34;).map(Number));
            this.ctx.moveTo(points[0][0], points[0][1]);
            this.ctx.lineTo(points[1][0], points[1][1]);
            this.ctx.lineTo(points[2][0], points[2][1]);
            this.ctx.closePath();
            this.ctx.fill();
          }

          this.ctx.setLineDash([]);
        }

        screenToCanvas(screenX, screenY) {
          const rect = this.canvas.getBoundingClientRect();
          const x = (screenX - rect.left - this.offsetX) / this.scale;
          const y = (screenY - rect.top - this.offsetY) / this.scale;
          return { x, y };
        }

        findNodeAt(x, y) {
          const canvasCoords = this.screenToCanvas(x, y);

          for (const node of Array.from(this.graph.nodes.values()).reverse()) {
            if (node.contains(canvasCoords.x, canvasCoords.y)) {
              return node;
            }
          }

          return null;
        }
      }

      class DragController {
        constructor(renderer) {
          this.renderer = renderer;
          this.isDragging = false;
          this.dragNode = null;
          this.dragOffsetX = 0;
          this.dragOffsetY = 0;

          this.bindEvents();
        }

        bindEvents() {
          this.canvas = this.renderer.canvas;
          this.canvas.addEventListener(&#34;mousedown&#34;, (e) => this.onMouseDown(e));
          this.canvas.addEventListener(&#34;mousemove&#34;, (e) => this.onMouseMove(e));
          this.canvas.addEventListener(&#34;mouseup&#34;, (e) => this.onMouseUp(e));
          this.canvas.addEventListener(&#34;mouseleave&#34;, (e) => this.onMouseUp(e));
        }

        onMouseDown(e) {
          const node = this.renderer.findNodeAt(e.clientX, e.clientY);

          if (node) {
            this.isDragging = true;
            this.dragNode = node;

            const canvasCoords = this.renderer.screenToCanvas(
              e.clientX,
              e.clientY
            );
            this.dragOffsetX = canvasCoords.x - node.x;
            this.dragOffsetY = canvasCoords.y - node.y;

            this.canvas.style.cursor = &#34;grabbing&#34;;
          }
        }

        onMouseMove(e) {
          if (!this.isDragging || !this.dragNode) {
            const node = this.renderer.findNodeAt(e.clientX, e.clientY);
            this.canvas.style.cursor = node ? &#34;grab&#34; : &#34;default&#34;;
            return;
          }

          const canvasCoords = this.renderer.screenToCanvas(
            e.clientX,
            e.clientY
          );
          const newX = canvasCoords.x - this.dragOffsetX;
          const newY = canvasCoords.y - this.dragOffsetY;

          this.dragNode.moveTo(newX, newY);
          this.renderer.render();
        }

        onMouseUp(e) {
          if (this.isDragging) {
            this.isDragging = false;
            this.dragNode = null;

            const node = this.renderer.findNodeAt(e.clientX, e.clientY);
            this.canvas.style.cursor = node ? &#34;grab&#34; : &#34;default&#34;;
          }
        }
      }

      class GraphEditor {
        constructor(canvasId) {
          const canvas = document.getElementById(canvasId);
          if (!canvas) {
            throw new Error(`Canvas element with id &#34;${canvasId}&#34; not found`);
          }

          this.canvas = canvas;
          this.graph = new Graph();
          this.renderer = new Renderer(canvas, this.graph);
          this.dragController = new DragController(this.renderer);
        }

        loadData(data) {
          this.graph.fromJSON(data);
          this.renderer.render();
        }

        getData() {
          return this.graph.toJSON();
        }

        save(filename = &#34;graph.json&#34;) {
          const data = this.getData();
          const jsonStr = JSON.stringify(data, null, 2);
          const blob = new Blob([jsonStr], { type: &#34;application/json&#34; });
          const url = URL.createObjectURL(blob);

          const a = document.createElement(&#34;a&#34;);
          a.href = url;
          a.download = filename;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);

          URL.revokeObjectURL(url);
        }

        async load() {
          return new Promise((resolve, reject) => {
            const input = document.createElement(&#34;input&#34;);
            input.type = &#34;file&#34;;
            input.accept = &#34;.json&#34;;

            input.onchange = (e) => {
              const file = e.target.files?.[0];

              if (!file) {
                reject(new Error(&#34;No file selected&#34;));
                return;
              }

              const reader = new FileReader();

              reader.onload = (event) => {
                try {
                  const content = event.target.result;
                  const data = JSON.parse(content);
                  this.loadData(data);
                  resolve();
                } catch (error) {
                  alert(&#34;Failed to parse JSON file&#34;);
                  reject(error);
                }
              };

              reader.onerror = () => {
                reject(new Error(&#34;Failed to read file&#34;));
              };

              reader.readAsText(file);
            };

            input.click();
          });
        }

        exportImage(filename = &#34;graph.png&#34;) {
          this.canvas.toBlob((blob) => {
            if (!blob) {
              console.error(&#34;Failed to create blob&#34;);
              return;
            }

            const url = URL.createObjectURL(blob);
            const a = document.createElement(&#34;a&#34;);
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            URL.revokeObjectURL(url);
          });
        }

        clear() {
          this.graph.clear();
          this.renderer.render();
        }
      }

      const exampleData = {
        nodes: [
          {
            id: &#34;company-a&#34;,
            x: 400,
            y: 100,
            label: &#34;公司A&#34;,
            width: 140,
            height: 60,
            style: {
              fill: &#34;#5B8FF9&#34;,
              stroke: &#34;#3366CC&#34;,
              strokeWidth: 2,
              textColor: &#34;#FFFFFF&#34;,
            },
          },
          {
            id: &#34;company-b&#34;,
            x: 250,
            y: 250,
            label: &#34;公司B&#34;,
            width: 140,
            height: 60,
            style: {
              fill: &#34;#61DDAA&#34;,
              stroke: &#34;#3BA272&#34;,
              strokeWidth: 2,
              textColor: &#34;#FFFFFF&#34;,
            },
          },
          {
            id: &#34;company-c&#34;,
            x: 550,
            y: 250,
            label: &#34;公司C&#34;,
            width: 140,
            height: 60,
            style: {
              fill: &#34;#65789B&#34;,
              stroke: &#34;#44566C&#34;,
              strokeWidth: 2,
              textColor: &#34;#FFFFFF&#34;,
            },
          },
          {
            id: &#34;company-d&#34;,
            x: 150,
            y: 400,
            label: &#34;公司D&#34;,
            width: 140,
            height: 60,
            style: {
              fill: &#34;#F6BD16&#34;,
              stroke: &#34;#C4940D&#34;,
              strokeWidth: 2,
              textColor: &#34;#FFFFFF&#34;,
            },
          },
          {
            id: &#34;company-e&#34;,
            x: 350,
            y: 400,
            label: &#34;公司E&#34;,
            width: 140,
            height: 60,
            style: {
              fill: &#34;#7262FD&#34;,
              stroke: &#34;#5243C9&#34;,
              strokeWidth: 2,
              textColor: &#34;#FFFFFF&#34;,
            },
          },
          {
            id: &#34;person-zhang&#34;,
            x: 650,
            y: 400,
            label: &#34;张三&#34;,
            width: 140,
            height: 60,
            style: {
              fill: &#34;#78D3F8&#34;,
              stroke: &#34;#47A4C9&#34;,
              strokeWidth: 2,
              textColor: &#34;#FFFFFF&#34;,
            },
          },
        ],
        edges: [
          {
            id: &#34;edge-1&#34;,
            source: &#34;company-a&#34;,
            target: &#34;company-b&#34;,
            style: {
              stroke: &#34;#999999&#34;,
              strokeWidth: 2,
              lineType: &#34;solid&#34;,
            },
          },
          {
            id: &#34;edge-2&#34;,
            source: &#34;company-a&#34;,
            target: &#34;company-c&#34;,
            style: {
              stroke: &#34;#999999&#34;,
              strokeWidth: 2,
              lineType: &#34;solid&#34;,
            },
          },
          {
            id: &#34;edge-3&#34;,
            source: &#34;company-b&#34;,
            target: &#34;company-d&#34;,
            style: {
              stroke: &#34;#999999&#34;,
              strokeWidth: 2,
              lineType: &#34;solid&#34;,
            },
          },
          {
            id: &#34;edge-4&#34;,
            source: &#34;company-b&#34;,
            target: &#34;company-e&#34;,
            style: {
              stroke: &#34;#999999&#34;,
              strokeWidth: 2,
              lineType: &#34;solid&#34;,
            },
          },
          {
            id: &#34;edge-5&#34;,
            source: &#34;company-c&#34;,
            target: &#34;person-zhang&#34;,
            style: {
              stroke: &#34;#999999&#34;,
              strokeWidth: 2,
              lineType: &#34;solid&#34;,
            },
          },
        ],
      };

      let editor;

      function init() {
        editor = new GraphEditor(&#34;graph-canvas&#34;);
        editor.loadData(exampleData);

        document.getElementById(&#34;save-btn&#34;).addEventListener(&#34;click&#34;, () => {
          editor.save(&#34;graph.json&#34;);
        });

        document
          .getElementById(&#34;load-btn&#34;)
          .addEventListener(&#34;click&#34;, async () => {
            await editor.load();
          });

        document.getElementById(&#34;export-btn&#34;).addEventListener(&#34;click&#34;, () => {
          editor.exportImage(&#34;graph.png&#34;);
        });

        document.getElementById(&#34;clear-btn&#34;).addEventListener(&#34;click&#34;, () => {
          if (confirm(&#34;确定要清空图表吗?&#34;)) {
            editor.clear();
          }
        });
      }

      if (document.readyState === &#34;loading&#34;) {
        document.addEventListener(&#34;DOMContentLoaded&#34;, init);
      } else {
        init();
      }
    
  

请马上打开vscode,新建一个Html,复制进去,右键Open with Live server看看效果吧😃,如果觉得不错请点赞收藏

相关推荐
AAA简单玩转程序设计15 小时前
谁说Java枚举只是“常量装盒”?它藏着这些骚操作
java·前端
前端小蜗15 小时前
💰该省省,该花花!我靠白嫖飞书,把“每日生存成本”打了下来
前端·程序员·产品
YaeZed15 小时前
Vue3-父子组件通信
前端·vue.js
优爱蛋白15 小时前
IL-21:后Th1/Th2时代的免疫新星
java·服务器·前端·人工智能·健康医疗
Mintopia15 小时前
💬 从猜想到架构:AI 聊天区域的 Web 设计之道
前端·前端框架·aigc
一过菜只因15 小时前
VUE快速入门
前端·javascript·vue.js
滴滴答答哒15 小时前
Quartz Cron 表达式参考表
前端·css·css3
匠心网络科技15 小时前
前端学习手册-JavaScript条件判断语句全解析(十八)
开发语言·前端·javascript·学习·ecmascript
我只会写Bug啊15 小时前
一文读懂:cookie、localStorage与sessionStorage的区别与应用
前端