从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也是这样) nodes和edges是唯一数据源,所有操作都基于它
💡 关键顿悟:图形库的本质不是"画图",是"管理节点和边的关系"
✅ 第二步:节点与边的"几何灵魂"
节点不能只是个坐标点,它需要:
- 能被点击(
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方法解决坐标转换问题:kotlinjs 编辑 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之后进行。
✅ 第四步:交互------让图"活"起来
拖拽不是简单移动,需要:
- 检测点击节点(
findNodeAt) - 计算拖拽偏移量(
dragOffsetX/Y) - 实时更新节点位置
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更有价值?
-
理解了"状态驱动渲染"
G6的
graph.changeData()本质就是更新nodes和edges,然后调render()。
我亲手实现了它,才明白"数据驱动视图"不是口号。 -
看清了库的"糖衣"
G6的
layout、animation、interaction都是在核心数据模型上叠加的 。
没有核心,这些"糖"都是浮云。 -
找回了对Canvas的热爱
以前用ECharts,像在用"画笔";现在手写,像在用代码指挥画布。
"Canvas不是画布,是你的编程战场。"
🌈 给同样迷茫的前端伙伴
如果你也在用库(D3/ECharts/G6),试试做这件事:
- 删掉所有库,只保留``
- 只实现一个功能:画一个带拖拽的节点
- 每次改一点:先能拖,再加边,再加保存
"不是你不够强,是库让你忘了自己能强。"
------ 这个项目我写了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>点击"保存"可导出JSON文件</li>
<li>点击"加载"可导入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 || "#5B8FF9",
stroke: config.style?.stroke || "#3366CC",
strokeWidth: config.style?.strokeWidth || 2,
textColor: config.style?.textColor || "#FFFFFF",
};
}
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 || "#999999",
strokeWidth: config.style?.strokeWidth || 2,
lineType: config.style?.lineType || "solid",
};
}
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("sourceNode", sourceNode.label);
console.log("offsetX1Y1X2Y2", 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 "";
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("2d");
this.graph = graph;
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
this.resizeCanvas();
window.addEventListener("resize", () => 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 + "px";
this.canvas.style.height = rect.height + "px";
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 = "14px Arial, sans-serif";
this.ctx.textAlign = "center";
this.ctx.textBaseline = "middle";
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("measureText", 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 + "...");
if (measuredTruncated.width <= maxWidth) {
return truncated + "...";
}
}
return "...";
}
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 === "dashed") {
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(" ")
.map((p) => p.split(",").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("mousedown", (e) => this.onMouseDown(e));
this.canvas.addEventListener("mousemove", (e) => this.onMouseMove(e));
this.canvas.addEventListener("mouseup", (e) => this.onMouseUp(e));
this.canvas.addEventListener("mouseleave", (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 = "grabbing";
}
}
onMouseMove(e) {
if (!this.isDragging || !this.dragNode) {
const node = this.renderer.findNodeAt(e.clientX, e.clientY);
this.canvas.style.cursor = node ? "grab" : "default";
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 ? "grab" : "default";
}
}
}
class GraphEditor {
constructor(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) {
throw new Error(`Canvas element with id "${canvasId}" 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 = "graph.json") {
const data = this.getData();
const jsonStr = JSON.stringify(data, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
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("input");
input.type = "file";
input.accept = ".json";
input.onchange = (e) => {
const file = e.target.files?.[0];
if (!file) {
reject(new Error("No file selected"));
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("Failed to parse JSON file");
reject(error);
}
};
reader.onerror = () => {
reject(new Error("Failed to read file"));
};
reader.readAsText(file);
};
input.click();
});
}
exportImage(filename = "graph.png") {
this.canvas.toBlob((blob) => {
if (!blob) {
console.error("Failed to create blob");
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
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: "company-a",
x: 400,
y: 100,
label: "公司A",
width: 140,
height: 60,
style: {
fill: "#5B8FF9",
stroke: "#3366CC",
strokeWidth: 2,
textColor: "#FFFFFF",
},
},
{
id: "company-b",
x: 250,
y: 250,
label: "公司B",
width: 140,
height: 60,
style: {
fill: "#61DDAA",
stroke: "#3BA272",
strokeWidth: 2,
textColor: "#FFFFFF",
},
},
{
id: "company-c",
x: 550,
y: 250,
label: "公司C",
width: 140,
height: 60,
style: {
fill: "#65789B",
stroke: "#44566C",
strokeWidth: 2,
textColor: "#FFFFFF",
},
},
{
id: "company-d",
x: 150,
y: 400,
label: "公司D",
width: 140,
height: 60,
style: {
fill: "#F6BD16",
stroke: "#C4940D",
strokeWidth: 2,
textColor: "#FFFFFF",
},
},
{
id: "company-e",
x: 350,
y: 400,
label: "公司E",
width: 140,
height: 60,
style: {
fill: "#7262FD",
stroke: "#5243C9",
strokeWidth: 2,
textColor: "#FFFFFF",
},
},
{
id: "person-zhang",
x: 650,
y: 400,
label: "张三",
width: 140,
height: 60,
style: {
fill: "#78D3F8",
stroke: "#47A4C9",
strokeWidth: 2,
textColor: "#FFFFFF",
},
},
],
edges: [
{
id: "edge-1",
source: "company-a",
target: "company-b",
style: {
stroke: "#999999",
strokeWidth: 2,
lineType: "solid",
},
},
{
id: "edge-2",
source: "company-a",
target: "company-c",
style: {
stroke: "#999999",
strokeWidth: 2,
lineType: "solid",
},
},
{
id: "edge-3",
source: "company-b",
target: "company-d",
style: {
stroke: "#999999",
strokeWidth: 2,
lineType: "solid",
},
},
{
id: "edge-4",
source: "company-b",
target: "company-e",
style: {
stroke: "#999999",
strokeWidth: 2,
lineType: "solid",
},
},
{
id: "edge-5",
source: "company-c",
target: "person-zhang",
style: {
stroke: "#999999",
strokeWidth: 2,
lineType: "solid",
},
},
],
};
let editor;
function init() {
editor = new GraphEditor("graph-canvas");
editor.loadData(exampleData);
document.getElementById("save-btn").addEventListener("click", () => {
editor.save("graph.json");
});
document
.getElementById("load-btn")
.addEventListener("click", async () => {
await editor.load();
});
document.getElementById("export-btn").addEventListener("click", () => {
editor.exportImage("graph.png");
});
document.getElementById("clear-btn").addEventListener("click", () => {
if (confirm("确定要清空图表吗?")) {
editor.clear();
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
请马上打开vscode,新建一个Html,复制进去,右键Open with Live server看看效果吧😃,如果觉得不错请点赞收藏