H5的Canvas如何画N叉树数据结构

        大家好。我是猿码叔叔,一位有着 5 年Java工作经验的北漂,业余时间喜欢瞎捣鼓,学习一些新东西来丰富自己。看过上一篇 Java 方法调用关系的老铁们,也许遗留了不少疑问,这Java方法调用关系可视化页面就这?这方法溯源菜单功能也没有实现啊?代码倒是挺全,拿来用还是有点麻烦!

        好!今天就是给老铁们道歉来了,并奉上一篇 Canvas 画 N 叉树的方法,同时一一告诉大家为何上述问题迟迟没有解决。

        以下,为了不重复造轮子,关于 HTML5 的 Canvas 介绍,以及如何创建都不在本篇范围内出现。大家可以去问文心一言。

一、N-ary树(N叉树)数据结构的应用

        N 叉树的应用场景还是很多的,比如你们公司的部门结构,人员结构,还有我刚刚说到的 Java 方法调用关系等等。或许你可以使用“X-mind”或“亿图脑图”等脑图软件,但对于开发项目集成来说,使用 H5 的 Canvas 来画一棵生动的树图是一件很酷的事。

二、画 N 叉树的准备工作

        1、准备 N 叉树的样例数据

        这里我准备的是每个对象含有 3 个字段:value(string)、countOfChild(int)、children(array)。

    const result = {
        value: 'Root',
        children:
            [
                {
                    value: '1',
                    children:[
                        {
                            value: '2', children:[]
                        },
                        {
                            value: '3',
                            children:[
                                {
                                    value: '4',
                                    children: []
                                },
                                {
                                    value: '5',
                                    children: []
                                },
                                {
                                    value: '6',
                                    children: [
                                        {
                                            value: '7',
                                            children: []
                                        },
                                        {
                                            value: '8',
                                            children: [
                                                {
                                                    value: '9',
                                                    children: []
                                                },
                                                {
                                                    value: '10',
                                                    children: []
                                                },
                                                {
                                                    value: '11',
                                                    children: []
                                                },
                                                {
                                                    value: '12',
                                                    children: []
                                                },
                                                {
                                                    value: '13',
                                                    children: []
                                                }
                                            ]
                                        },
                                    ]
                                }
                            ]
                        },
                        {
                            value: '14', children:[]
                        }
                    ]
                },
                {
                    value: '15',
                    children:[
                        {
                            value: '16', children:[]
                        },
                        {
                            value: '17', children:[]
                        }
                    ]
                },
                {
                    value: '18',
                    children:[
                        {
                            value: '19', children:[]
                        },
                        {
                            value: '20', children:[]
                        }
                    ]
                },
                {
                    value: '21',
                    children:[
                        {
                            value: '22', children:[]
                        },
                        {
                            value: '23', children:[]
                        },
                        {
                            value: '24', children:[]
                        },
                        {
                            value: '25', children:[]
                        }
                    ]
                }
            ]
    }

        2、如何确定每个节点的位置

        canvas 画图与 H5 写 div 或者 span 元素不同的是,后者可以用 display 或者 margin 来设置位置。前者则需要一定的数学知识,即 xy 坐标。然后你想画什么形状?圆形(circle/arc)、长方形(rectangle)或者贝塞尔曲线(curve)等。这里我们要画的是“长方形”。所以我们得需要知道怎么用 Canvas 画一个长方形?

<!-- h5代码 -->
<canvas width="400" height="400" id="myCanvas"></canvas>

<!-- JS 代码 -->
window.onload = () => {
   draw();
};

draw = () => {
  const myCanvas = document.querySelector('#myCanvas');
  const ctx = myCanvas.getContext('2d');
  // 前面两个数字对应 x、y 坐标,后两个数字对应宽(w)和高(h)
  ctx.strokeRect(200, 200, 100, 80);
};

        IDEA中右键创建 HTML5 文件,然后运行上面的代码你就会看到一个宽100px和高80px的长方形了。

        为什么画长方形,常见的N叉树都是用圆形代表一个节点,这里画长方形你可以把他想象成圆形。

        3、如何用直线连接两个节点

        画!Canvas 既然提供画方形的函数,直线也是有的。

  draw = () => {
    const myCanvas = document.querySelector('#myCanvas');
    const ctx = myCanvas.getContext('2d');

    const rec1X = 200, rec1Y = 200, rec1W = 100, rec1H = 80;
    ctx.strokeRect(rec1X, rec1Y, rec1W, rec1H);

    // 两个矩形的 x 坐标相同,y 不同时纵向平移;x 不同,y 相同时水平平移
    const rec2X = rec1X, rec2Y = rec1Y + 200;

    ctx.strokeRect(rec2X, rec2Y, rec1W, rec1H);

    // 移动到第一个矩形的底部中央。xy中的 x 位于矩形左下角,y 位于矩形顶部中央,现在你可以计算如何移动了
    ctx.moveTo(rec1X + (rec1W >> 1), rec1Y + rec1H);
    ctx.lineTo(rec2X + (rec1W >> 1), rec2Y);
    ctx.stroke();
  };

        此部分代码要特别注意的是线段的两个端点移动的位置的计算方式。moveTo 是第一个端点的位置,lineTo 是将线段指向的目标位置。在不了解如何移动时,可以先随便填两个数字,然后根据偏差,找出计算规律即可。

        4、如何填充文字(fillText)

        将文字填充到 Canvas 画的矩形中时,与 HTML 创建一个 span 元素,然后键入文字,最后通过 css 样式设置 border、padding 等等不同的是,矩形与文字是完全两个互不相干的事情,唯一直观上让文字嵌入在矩形内部中央的是两者的 xy 坐标。这有点像刚刚画线的那个思路。

  draw = () => {
    const myCanvas = document.querySelector('#myCanvas');
    const txtHeight = 30;
    const ctx = myCanvas.getContext('2d');
    ctx.font = `${txtHeight}px Arial`;

    const root = "root", child = 'child';
    const rec1X = 200, rec1Y = 200, rec1W = 100, rec1H = 80;
    ctx.strokeRect(rec1X, rec1Y, rec1W, rec1H);

    const txtWidth = ctx.measureText(root).width;
    ctx.fillText(root, rec1X + (rec1W - txtWidth >> 1), rec1Y + (rec1H >> 1) + txtHeight / 3);
    // 两个矩形的 x 坐标相同,y 不同时纵向平移;x 不同,y 相同时水平平移
    const rec2X = rec1X, rec2Y = rec1Y + 200;

    const childTxtWidth = ctx.measureText(child).width;
    ctx.strokeRect(rec2X, rec2Y, rec1W, rec1H);
    ctx.fillText(child, rec2X + (rec1W - childTxtWidth >> 1), rec2Y + (rec1H >> 1) + txtHeight / 3);
    // 移动到第一个矩形的底部中央。xy中的 x 位于矩形左下角,y 位于矩形顶部中央,现在你可以计算如何移动了
    ctx.moveTo(rec1X + (rec1W >> 1), rec1Y + rec1H);
    ctx.lineTo(rec2X + (rec1W >> 1), rec2Y);
    ctx.stroke();
  };

        关于文字的xy坐标计算方式,咱们还是不在注释里强调。x位于矩形的左下角顶点,我们要将x移动到矩形中央,假设矩形的底部宽为 w,那么第一步就是 x + w / 2。这时候你会发现文字的 x 坐标并没有居中,这里我们还要减去文字宽度的一半才能居中,就是 (x + (矩形宽度- 文字宽度 / 2))。与 y 坐标计算不同的是,以 x 坐标为准的物体通常靠右。你可以拿起你上学时的尺子,观察它的刻度,x 坐标的标准就是,物体在刻度的右侧,而以 y 坐标为标准的物体则在刻度左侧或者是上侧,所以当 (y + 矩形高度 / 2) 同时还要再加上 (文字高度 / 3)。

三、开始画 N 叉树

        画 N 叉树之前,有一个严肃的问题。这个问题不是我刚要画就能想出来的,而是我在实践过程中发现的,现在分享给大家。N 叉树有很多个层级,为树的高度,他不像“完美二叉树”那样完美,你闭着眼睛就可以想象每个节点的分布,在计算其叶子节点时,知道高度就能知道叶子节点的个数,这样有利于我们划分每个节点的“占用宽度”。N 叉树计算每个节点的占用宽度时,需要知道以当前节点为父节点的叶子节点个数。你可以回到 2.1 查看那个样例数据。那棵树一共有 18 个叶子节点。第二层的每个节点所拥有的叶子节点是不同的,分别为 {10, 2, 2, 4}。这意味着拥有 10 个叶子节点的节点将占用 canvas 宽度的 10/18。其他节点的计算方式都是如此,但

当当前节点的叶子节点个数为 0 时,你需要考虑到为当前节点留余地,前提是你计算当前节点的占位宽度是以叶子节点个数为先决条件。

        我们知道了如何为每个节点划分占位宽度,那又如何为每个节点设置 xy 坐标呢?这里如果你对第二部分的内容阅读的够仔细,相信你应该能够有点眉目,也就是 2.3 和 2.4 那些部分。 

    function draw() {
        // const ret = {};
        cntOfEachLevel();
        console.log(result);
        const ctx = myCanvas.getContext('2d');
        ctx.font = "20px Arial";
        const text = result.value;
        const div = canvasWidth / result.cntOfChild;
        // 使用 measureText 测量文本宽度
        const textWidth = ctx.measureText(text).width;
        const cx1 = canvasWidth / 2 - textWidth, cy1 = 10;
        const shapeWidth = textWidth + 20;
        const txtX = cx1 + 10, txtY = cy1 + 19;
        ctx.beginPath();
        ctx.fillText(text, txtX, txtY);
        ctx.strokeRect(cx1, cy1, shapeWidth, 25);
        drawChildren(ctx, cx1, cy1, 0, shapeWidth, result.children, div, 1);
    }

    const levelHeight = 100, canvasWidth = 2000;

    drawChildren = (ctx, cx1, cy1, l, pShapeWidth, arr, div, level) => {
        let n = arr.length;
        if (n === 0) return;
        let pre = l;
        for (let i = 0; i < n; ++i) {
            let curWidth = div * arr[i].cntOfChild;
            curWidth = curWidth === 0 ? div : curWidth;
            const textWidth = ctx.measureText(arr[i].value).width;
            const shapeWidth = textWidth + 20;
            const cx2 = pre + (curWidth >> 1), cy2 = cy1 + levelHeight;
            ctx.beginPath();
            ctx.strokeRect(cx2, cy2, shapeWidth, 25);
            const textX = cx2 + 10, textY = cy2 + 19;
            ctx.fillText(arr[i].value, textX, textY);
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(cx1 + pShapeWidth / 2, cy1 + 25);
            ctx.lineTo(cx2 + shapeWidth / 2, cy2);
            ctx.stroke();
            // xys[i] = {x: cx2, y: cy2, x2: cx2 + shapeWidth, y2: cy2 + 25};
            if (arr[i].children.length > 0) {
                drawChildren(ctx, cx2, cy2, pre, shapeWidth, arr[i].children, div, level + 1)
            }
            pre += curWidth;
        }

    }

    // myCanvas.addEventListener('click', function(e) {
    //     const x = e.offsetX, y = e.offsetY;
    //     for (let i = 0; i < xys.length; ++i) {
    //         const obj = xys[i];
    //         if (obj.x <= x && x <= obj.x2 && obj.y <= y && y <= obj.y2) {
    //             alert(arr[i]);
    //         }
    //     }
    // });

    function countNodeOfEachLevel(obj, ret, level) {
        ret[level] = ret[level] == null ? 1 : ret[level] + 1;
        for (const c of obj.children) {
            countNodeOfEachLevel(c, ret, level + 1);
        }
    }

    function cntOfEachLevel() {
        result['cntOfChild'] = dfs(result);
    }

    function dfs(obj) {
        if (obj.children.length === 0) {
            obj['cntOfChild'] = 0;
            return 1;
        }
        let cnt = 0;
        for (const c of obj.children) {
            cnt += dfs(c);
        }
        obj['cntOfChild'] = cnt;
        return cnt;
    }

        以上代码就是成品了。其中涉及到计算当前节点的叶子节点个数的小算法 你可以拿去运行,就可以看到如下图片。

        如果你想自定义文字内容,记得将 canvas 的宽度设定的足够大来适应每个节点的占位分布。最好是途中的数字。

四、结语

        创作不易,欢迎读者的支持与点评。之所以想通过 Canvas 画 N 叉树,是因为上一篇的 Java 方法调用关系可视化页面不够精彩,但 Canvas 的学习和实践着实有挑战性,想要玩转它并非易事。我近乎是 0 基础学习的 Canvas,现在看来困难不是最可怕的,可怕的是不去动手实践。我在力扣学习动态规划时,总觉得他理解起来很费事,但当你先用暴力解法解开一道算法题时,再优化暴力解法,你会发现动态规划其实就是暴力解法的升级版。大问题是可以拆分的,咱们今天这个 N 叉树本质就是两个节点如何连线的基础问题,解决了这个问题,剩下的就是复制了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猿码叔叔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值