今年三月份的时候,我写过一篇博客 树的创建、遍历及可视化。这里我主要是通过代码生成树,接着把树对象序列化成 JSON,然后利用其它技术将把树可视化。当时觉得,这样做非常直观,能看到树到底是什么样,并且序列化后的 JSON 也可以持久化保存,想要恢复原来的树对象,也很简单。当时就自然的萌生出另一个想法:我现在是实现了 JSON -> 可视化树,如果反过来实现呢?即通过 可视化树 -> JSON。 当时考虑了一下,我是敲定了利用前端技术 Canvas 来做,但是因为我不是前端开发,只会一些简单的 js。后来,感觉挺麻烦的,就放弃了。不过这个想法,我一直记得,偶尔还是能想起。上个星期,正好有时间,我就又开始思考这个问题了,并且初步有了一些眉目,我感觉我的想法是可行的,不过需要解决一些技术上的难题。然后星期天我就开始正式写代码了,一直到今天,我终于完成了一个简单的 demo。
菩提本无树
演示
生成的 json 利用 Python 库的可视化(见上一篇博客中提到的几种可视化方法之一)
代码
复制下面代码,浏览器打开(Chrome、新版 Edge),打开控制台,拖拽形成树,然后控制台调用 Serializer() 方法。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#canvas {
border: 2px solid red;
}
</style>
</head>
<body>
<div id="content">
<canvas id="canvas" width="800" height="800">
</canvas>
</div>
<script>
// 定义一些需要的全局变量
let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
let WIDTH = canvas.width, HEIGHT = canvas.height;
ctx.font = "25px Consolas"; // 设置字体
let DISTANCE_CENTER = 10 * 10, // 圆心区域大小平方
DISTANCE_ROUND = 25 * 25, // 原内区域大小平方
ROOT = 0, CHILD = 1, // 定义常量,区分根和孩子节点
GLOBAL_IS_DOWN = false, // 鼠标是否按下
GLOBAL_IS_CENTER = false, // 鼠标是否在节点中心
GLOBAL_IS_ROUND = false, // 鼠标是否在圆内(但不包括中心)
GLOBAL_CURRENT_INDEX = -1, // 鼠标选中的节点下标,默认是 -1
GLOBAL_POINT = { x: -1, y: -1 }, // 记录上一个有效的点
NODES = new Array(), // 保存节点的数组,图的顶点
EDGES = new Map(); // 保存箭头的数组,图的边
// 鼠标按下
canvas.onmousedown = e => {
GLOBAL_IS_DOWN = true;
GLOBAL_POINT = { x: -1, y: -1 }; // 每次鼠标按下,重新初始化
let x = e.pageX - canvas.offsetLeft;
let y = e.pageY - canvas.offsetTop;
for (let i = 0; i < NODES.length; i++) {
let node = NODES[i];
dd = (node.x - x) * (node.x - x) + (node.y - y) * (node.y - y);
if (dd <= DISTANCE_CENTER) {
GLOBAL_IS_CENTER = true;
GLOBAL_CURRENT_INDEX = i;
break;
} else if (dd <= DISTANCE_ROUND) {
GLOBAL_IS_ROUND = true;
GLOBAL_CURRENT_INDEX = i;
break;
}
}
}
// 鼠标移动
canvas.onmousemove = e => {
if (!GLOBAL_IS_DOWN) {
return
}
let x = e.pageX - canvas.offsetLeft;
let y = e.pageY - canvas.offsetTop;
if (GLOBAL_IS_CENTER) {
// 绘制图形
drawGraph();
// 绘制线条
drawTempArrow(x, y);
}
if (GLOBAL_IS_ROUND) {
// 修改指定的节点
NODES[GLOBAL_CURRENT_INDEX].x = x;
NODES[GLOBAL_CURRENT_INDEX].y = y;
// 重新绘制
drawGraph();
}
}
// 鼠标松开
canvas.onmouseup = e => {
// 判断点是否落在节点上
let x = e.pageX - canvas.offsetLeft;
let y = e.pageY - canvas.offsetTop;
let lastIndex = -1;
for (let i = 0; i < NODES.length; i++) {
let node = NODES[i];
dd = (node.x - x) * (node.x - x) + (node.y - y) * (node.y - y);
if (dd <= DISTANCE_ROUND) {
GLOBAL_IS_ROUND = true;
lastIndex = GLOBAL_CURRENT_INDEX;
GLOBAL_CURRENT_INDEX = i;
break;
}
}
if (GLOBAL_IS_ROUND && lastIndex != -1) {
// 如果不是同一个点就绘制
if (lastIndex != GLOBAL_CURRENT_INDEX) {
// 需要考虑边重复创建的问题和反向绘制的问题
createEdge(lastIndex, GLOBAL_CURRENT_INDEX);
}
}
drawGraph();
// 全局变量状态恢复
GLOBAL_IS_DOWN = false;
GLOBAL_IS_CENTER = false;
GLOBAL_IS_ROUND = false;
GLOBAL_CURRENT_INDEX = -1
}
class Node {
constructor(name, x, y, radius, color, nodeType) {
this.name = name;
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
this.nodeType = nodeType;
this.kind = "NODE";
}
}
// 绘制所有的节点
function drawNodes() {
// 简单设置一些样式
ctx.lineWidth = 2;
// 绘制节点
NODES.forEach(e => {
ctx.fillText(e.name, e.x, e.y);
ctx.strokeStyle = e.color;
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius, 0, 2 * Math.PI);
ctx.stroke();
})
// 绘制边
EDGES.forEach((es, i) => {
let head = NODES[i];
es.forEach(e => {
let tail = NODES[e];
ctx.beginPath();
ctx.strokeStyle = "red";
ctx.moveTo(head.x, head.y);
// // 两个节点间的距离
let d = Math.sqrt((head.x - tail.x) * (head.x - tail.x) + (head.y - tail.y) * (head.y - tail.y));
let sinX = Math.abs(head.y - tail.y) / d;
let cosX = Math.abs(head.x - tail.x) / d;
// 所以,箭头与指向的节点的交点可由此计算得出
let x1 = tail.x + 25 * cosX;
let y1 = tail.y - 25 * sinX;
// 考虑在左右两种情况
if (x1 < Math.min(head.x, tail.x) || x1 > Math.max(head.x, tail.x)) {
x1 = tail.x - 25 * cosX;
}
if (y1 < Math.min(head.y, tail.y) || y1 > Math.max(head.y, tail.y)) {
y1 = tail.y + 25 * sinX;
}
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = "green";
ctx.moveTo(x1, y1);
ctx.lineTo(tail.x, tail.y);
ctx.stroke();
})
})
}
// 绘制临时箭头
function drawTempArrow(x, y) {
ctx.beginPath();
ctx.moveTo(NODES[GLOBAL_CURRENT_INDEX].x, NODES[GLOBAL_CURRENT_INDEX].y);
ctx.lineTo(x, y);
ctx.stroke();
}
function createEdge(head, tail) {
// 默认第一个节点是根节点,降低处理难度
// 如果是从某节点指向根节点,直接报错
if (tail == 0) {
console.log("Are you kidding me?");
return;
}
// 判断重复的指向问题,如果一个节点已经被指向
// 例如:如果 A --> B,则其他节点无法再指向 B
for (let values of EDGES.values()) {
for (let i = 0; i < values.length; i++) {
if (values[i] == tail) {
console.log("Repeat narrow!")
return
}
}
}
let edge = EDGES.get(head);
if (edge) {
edge.push(tail)
} else {
edge = new Array();
edge.push(tail);
EDGES.set(head, edge);
}
}
function drawGraph() {
// 清空canvas
ctx.clearRect(0, 0, WIDTH, HEIGHT);
// 重新绘制
drawNodes();
}
function verify() {
// TODO 验证当前图是否是一棵树
// 因为已经做了处理,所以是无法形成环,所以
// 我应该只需要要判断 n 个节点有 n-1 条边就可以了(这里不一定对)
let count = 0;
for (let values of EDGES.values()) {
count += values.length;
}
if (count == NODES.length - 1) {
return true;
} else {
return false;
}
}
function Serializer() {
// 生成树 JSON 表示
// {
// "name": "",
// "children": [
// "name": "",
// "children": []
// ]
// }
if (!verify()) {
console.log("This is not a tree.")
return
}
tree = DFS(0);
console.log(JSON.stringify(tree, null, 4));
}
function DFS(v) {
// 采用递归的方式,深度遍历图的同时,生成图的json对象表示
let child = {
"name": NODES[v].name, // 似乎数字类型用python的库无法正常显示
"children": []
}
let vexs = EDGES.get(v)
if (vexs) {
for (let i = 0; i < vexs.length; i++) {
let sub_child = DFS(vexs[i])
if (sub_child) {
child["children"].push(sub_child)
}
}
}
return child
}
function test() {
let root = new Node('A', 200, 50, 25, 'green', ROOT);
let child1 = new Node('B', 350, 50, 25, 'red', CHILD);
let child2 = new Node('C', 500, 50, 25, 'red', CHILD);
let child3 = new Node('D', 650, 50, 25, 'red', CHILD);
let child4 = new Node('E', 200, 350, 25, 'red', CHILD);
let child5 = new Node('F', 350, 350, 25, 'red', CHILD);
let child6 = new Node('G', 500, 350, 25, 'red', CHILD);
let child7 = new Node('H', 650, 350, 25, 'red', CHILD);
let child8 = new Node('Z', 650, 550, 25, 'red', CHILD);
NODES.push(root);
NODES.push(child1);
NODES.push(child2);
NODES.push(child3);
NODES.push(child4);
NODES.push(child5);
NODES.push(child6);
NODES.push(child7);
NODES.push(child8);
// 绘制
drawNodes(NODES);
}
test();
</script>
</body>
</html>
思考
我刚开始做的时候,还不知道所有节点组成的是一个图(没有想到这里)。不过后来发现,这不就是数据结构里面的图吗?然后问题就转化为如何判定一个有向图是一棵树了。然后,我请教了人工智能,哈哈。这个答案的质量真是不错!
绘制对象
这里简单介绍一下,几个关键的元素。
节点:一个给定半径的圆。
箭头:从一个节点的圆心,指向另一个节点。(被指向的节点,箭头和和圆交点到圆心的绿色线条。)
节点,有两个点击区域:
- 圆心。
- 圆内不包括圆心。
绘制的逻辑是:
- 如果点击了圆心并且拖动,会绘制一根线条,相当于一个带指向的箭头,如果鼠标松开在另一个节点(当然不包括自身了)中,那么会绘制一个上述的箭头。
- 如果点击了圆内并且拖动,会拖动节点进行移动。
- 箭头是从一个节点指向另一个节点。如果一个节点已经被指向了,那么其他节点就无法再次指向它。 所以一个节点只能被一个节点指向,并且它自身可以指向其他多个节点。
绘制对象保存
所有的节点保存在一个全局的 NODES 数组中,所有的箭头保存在 EDGES Map 中。
EDGES 的一个简单示例:
{
0: [1, 2],
1: [3, 4],
4: [5]
}
EDGES 就是图的邻接表结构了,不过,里面存放的是 NODES 节点的索引,不是 NODE 对象,这样处理起来方便一些。
序列化
关于判定图是树的问题,因为前面加了限制,反而简单了。我只需要判断这个条件即可:边数 == 节点数-1。所以,剩下的难题就是树的序列化了。这里是一个简洁的递归写法,我刚开始是使用的迭代写法。但是在迭代的时候生成树的结构真的难受,需要添加一些额外的处理。不然,孩子节点和双亲结点位置对不上了。后来,我去看了这种递归的写法,真是奇妙,你完全不用管其它的了。遍历的顺序就是每个对象的顺序,直接就生成了,很神奇。让我又想起来来那句著名的话:To iterate is human, to recurse, divine.
这里提供一下验证的 Python 程序
我是用 Python 先来验证想法可行,再移植到 JS 的,毕竟直接写的话,太为难我了。
from typing import Dict, List
import json
# A 是根节点
Graph = {
"A": ["B", "C", "D"],
"B": ["E", "F"],
"F": ["G", "H"],
"H": ["Q", "P"],
"Q": ["U", "V"],
"V": ["Y", "Z"]
}
def BFS(graph: Dict[str, List[str]]) -> str:
"""
Graph DFS
"""
vexs = graph["A"]
queue = []
children = []
tree = {
"name": "A",
"children": []
}
queue.append(tree["name"])
children.append(tree)
while queue:
vex = queue.pop(0)
child = children.pop(0)
vexs = graph.get(vex)
if vexs:
queue.extend(vexs)
for vex in vexs:
sub_child = {
"name": vex,
"children": []
}
child["children"].append(sub_child)
children.append(sub_child)
return json.dumps(tree, indent=4)
def DFS(v: str) -> Dict[str, List[str]]:
child = {
"name": v,
"children": []
}
vexs = Graph.get(v)
if vexs:
for vex in vexs:
sub_child = DFS(vex)
if child:
child["children"].append(sub_child)
return child
if __name__ == "__main__":
"""
单纯的遍历操作,递归和非递归的处理难度差不多。
但是对于生成嵌套结构的方式,递归是最佳的方式。无需处理嵌套,它是自然而然的。
人理解迭代,神理解递归。
"""
tree_json = BFS(Graph)
print(tree_json)
tree_dic = DFS("A")
print(json.dumps(tree_dic, indent=4))
总结
本来我还打算进一步完善这个代码的,增加删除节点、箭头的功能,不过因为前端技术实在是难为我这个后端开发了。所以我可能就放弃继续改进的想法了。也许,有时间会继续去完善它。这里就先把它给记录下来,也希望感兴趣的同学可以看一下。我觉得把这个功能做成一个静态网页也蛮好的。不过这里只是一个 demo,代码可能还有一堆问题呢,我反正已经是验证了那个想法了 —— 可视化树 -> JSON。