接着前两篇内容,来聊聊如何从零到一实现一个简单的技术雷达。

写在前面

在 2020 年,我曾写过两篇内容,简单介绍了如何定制属于你自己的技术雷达:《上篇》《中篇》。在这两篇文章中,我刨析了 Thoughtworks 新版的在线技术雷达实现细节,并完成了动态添加技术栈坐标以及对这些坐标进行更简单维护的方案。

由于官方并未公开这个版本源代码、以及未提供公开 API 接口。我们后续想针对技术雷达进行调整和修改,其实还是比较困难的。作为一个追求程序执行效率的人,也很难忍受一个页面只是脚本就拥有接近 5MB 体积。

经过再三考虑,我决定从零到一,完成一个更简单的技术雷达小工具。在正式开动之前,我们先来确定设计思路。

设计思路

技术雷达图 Demo

相比较 Thoughtworks 版本的技术雷达选择使用 D3 作为渲染框架,我选择了生态更新一些的 ECharts。最终程序编写完成后,在不使用 Gzip 压缩的情况下,程序产物仅 36 KB(压缩后不到 30KB),算上各种依赖也仅仅需要再增加 640KB (压缩后不到 600 KB)。

页面首次资源加载状况

针对“坐标系”的维护,考虑完全放弃掉极坐标体系,为每一个象限分配一个独立的直角坐标系,这样所有的坐标就都可以使用不需要转换的简单数值来进行维护了,也减少了后续针对视图切换场景,需要额外进行处理的工作量。也就不必再维护前文中为了实现所见即所得,而编写的“两种坐标系之间相互转换的工具”了,毕竟跨坐标系操作,难以避免精度丢失的问题。创建和维护都使用一种坐标系的话,这类问题直接就规避掉了。

为了让程序运行更加高效,整体代码量少一些,我期望每一个象限数据和事件都是完全独立的(全局级别、应用级别)。

这里偷懒采用了 iframe 沙盒。这个方案唯一缺陷是首次加载 iframe 页面和资源会重复加载四次(因为浏览器并发机制)。如果不追求四个象限同时渲染,可以考虑使用脚本控制其中一个象限完全加载完毕之后,再继续加载其余的象限,则可以规避掉这个问题,减少数据下载量。

使用直角坐标系和iframe实现四象限

同时,为了完成侧边栏和几个独立的雷达象限的互动,我们还需要做一点点琐碎的工具,完成不同页面的程序的数据交互问题,以及应用数据的保存和导入功能。

不同“页面”需要进行通信

使用 EChart 完成雷达图的基础实现

想要用数据可视化工具实现一个具备上文中交互和展示形式的雷达图,其实还是有一点点麻烦的,因为相比单纯的雷达图或者折线图而言,技术雷达图包含了一些复合的状态:

  • 需要在直角坐标系中包含四个尺寸不同的环形象限。
  • 需要同时展示每个数据点不同的状态(新增、默认、更高的趋势、更低的趋势)以及搭配鼠标交互完成(焦点、失焦)状态的展示。

一个相对简单的解决思路是不追求在单一一种类型中完成所有内容的渲染,而是将渲染的内容按照类型进行拆分,然后使用“叠加”的方式来解决问题。

首先来解决“四个不同的环形象限”,环形区域可以使用两种方式来实现,一种是借助单一radar ,通过设置 splitNumber 属性来完成针对内容的水平切分。而另外一种则是直接使用四个 radar ,通过设置不同的半径,来完成更非水平分布的绘制。比如 30%、55%、78%、90%,看着也更符合逻辑,被信任和采纳的技术区域只占三成,需要被废弃的技术在一成左右,剩余六成用来存放“正在经历验证”和“感兴趣,等待评估”。

接着来解决不同“技术数据点”的渲染,分别针对数据点的不同状态,定义不同的 series 类型。

因为鼠标和数据点交互,会产生的“激活焦点”和“失去焦点”两种展示状态,所以我们还需要再定义一份失焦状态的数据类型,因为只是视觉呈现有差异,这两种状态中的数据位置、数据类型其实是完全一致的,所以通过镜像原本 series 数据就能轻松搞定(技术雷达图中本身数据量并不大,性能损耗微乎极微)

上面思路的代码实现会类似下面:

<body>
    <div id="main"></div>

    <script src="/assets/libs/echarts.min.js"></script>
    <script>
        function createChartConatiner(radarSize, id = "main") {
			...
       }

        const options = { 
            "tooltip": { "triggerOn": "none", "position": "bottom" }, 
            "grid": { "show": false, "left": 0, "top": 0 }, 
            "xAxis": { "min": 0, "max": 100, "type": "value", "axisLine": { "show": false }, "axisTick": { "show": false }, "axisLabel": { "show": false }, "splitLine": { "show": false }, "maxInterval": 1 }, 
            "yAxis": { "min": -100, "max": 0, "type": "value", "axisLine": { "show": false }, "axisTick": { "show": false }, "axisLabel": { "show": false }, "splitLine": { "show": false }, "maxInterval": 1 }, 
            "series": [
                { "id": "techPoint", "itemStyle": "..."}, 
                { "id": "techPoint-blur", "itemStyle": "..."}, 
                { "id": "type1-inner-border", "itemStyle": "..."}, 
                { "id": "type1-border", "itemStyle": "..."}, 
                { "id": "type1-inner-border-blur", "itemStyle": "..."}, 
                { "id": "type1-border-blur", "itemStyle": "..."}, 
                { "id": "type2-quadrant4", "itemStyle": "..."}, 
                { "id": "type2-quadrant4-blur", "itemStyle": "..."}, 
                { "id": "type3-quadrant4", "itemStyle": "..."}, 
                { "id": "type3-quadrant4-blur", "itemStyle": "..."}, 
            ],
            "radar": [
                { "z": -1, "radius": 130.5, "center": [0, 0], "splitArea": "..."},
                { "z": -2, "radius": 239.25000000000003, "center": [0, 0], "splitArea": "..."},
                { "z": -3, "radius": 339.3, "center": [0, 0], "splitArea": "..."},
                { "z": -4, "radius": 391.5, "center": [0, 0], "splitArea": "..."},
            ] 
        };

        echarts.init(createChartConatiner(800, "main")).setOption(options);
    </script>
</body>

将代码运行起来,我们可以得到类似下面的可视化效果:

使用上面思路绘制的单一象限

简单逻辑完成雷达图四象限的创建

当通过上面的方式完成了基础的雷达图绘制之后,只要在页面中添加四个 iframe 就可以完成包含四个象限的技术雷达的基础绘制了。

相比较 “Hard Code” 四个页面标签,我更推荐使用脚本来进行创建,在完成基础功能的前提下,还能解决下面两个问题:

  • 针对不同设备调整雷达图的尺寸
  • 前文提到的,控制象限加载时机,来解决首次载入无法利用资源缓存的问题

一段简单的示意代码:

function init() {
...

    [2, 1, 3, 4].forEach(function (chartId) {
        var iframe = document.createElement('iframe');
        iframe.width = size + 'px';
        iframe.height = size + 'px';
        iframe.className = "radar-quadrant radar-quadrant-" + chartId;
        iframe.style.margin = marginSize + 'px';
        iframe.src = '/page/radar-chart.html?quadrant=' + chartId + (queries.size ? '&size=' + queries.size : '');
        iframe.setAttribute('frameborder', '0');
        conatiner.appendChild(iframe);
    });
}

细心的同学能够看到这里的 iframe 加载次序有一点“特别”,象限 ID 加载顺序为 “2,1,3,4”,这是因为浏览器中默认元素布局是从左至右,而非和数学中的四象限的启始象限一致。

如果希望 iframe 加载次序和自然顺序保持一致,可以考虑在数据转换的时候,对调 1,2 象限的数值处理方式即可,例如:

function fixValueForQuadrant(src, quadrantId) {
  return src.map(function (point) {
    const [x, y, type, state] = point;
    switch (quadrantId) {
      case 1:
        return [x, y, type, state];
      case 2:
        return [x * -1, y, type, state];
      case 3:
        return [x * -1, y * -1, type, state];
      case 4:
        return [x, y * -1, type, state];
    }
  });
}

简单逻辑完成页面数据通信

在完成了象限的加载之后,就需要考虑如何实现页面数据交互了,比如:鼠标选择侧边栏中的技术点,让雷达图中对应的技术点产生高亮的效果,同时对所有非选择元素所在象限进行数据变暗的失焦处理等等。

这里实现的方式可以很简单,以高频率使用的“使用鼠标点击雷达图上或页面技术列表中的技术点”为例。

先在调用 iframe 的页面的脚本中添加一个简单的 messageBus,定义一些消息类型:

...
function messageBus(type, idx, data) {
    switch (type) {
        case 'click':
            ...
            break;
        case 'mouseenter':
            if (!isMouseFree) return;
            isMouseFree = false;
            ...
            if (data.trigger === 'event') {
                window.frames[lastHoverPoint[0]] && window.frames[lastHoverPoint[0]].onPointerOut(lastHoverPoint[1]);
            }
            clearTimeout(preventDragFlickerHandler);
            break;
        case 'mouseout':
            isMouseFree = true;
            preventDragFlickerHandler = setTimeout(function () {
              ...
            }, 150);
            break;
        case 'getLabel':
            var point = techPoints[idx - 1].list.filter(function (item) {
                return item.id === data.pId;
            })[0];
            return point ? point.title : "尚未设置";
        default:
            ...
            break;
    }
}

window.messageBus = messageBus;

接着,在 iframe 中绘制具体雷达图的脚本中完成可以被调用的部分,除了针对“原生”的事件进行支持外,可以添加一个参数针对“自定义”事件进行特殊处理。当雷达图上的“技术点”获取焦点之后,便会调用上文中定义的 messageBus 执行具体的逻辑。

....

function focusPoint(pId, isEvent) {
  const id = isEvent ? pId : pId - 1;
  radarChart.dispatchAction({ type: "showTip", seriesIndex: 0, dataIndex: id });
  quadrantData = quadrantData.map((item, idx) => {
    if (idx !== id) item[3] = 1;
    return item;
  });
  sendEventMessage('mouseenter', id, { quadrant: queryQuadrant, trigger: isEvent ? 'event' : 'custom' });
  drawChart(quadrantData, false);
}
window.onPointerEnter = focusPoint;

...

当然,除了雷达图上的交互之外,还存在侧边栏的交互需要处理,在侧边栏合适的元素的交互事件中添加类似下面的代码即可,如果你想抽象的更干净,同样可以封装到 messageBus 中。

var lastHoverPoint = [0, 0];
window.frames[lastHoverPoint[0]].onPointerOut(lastHoverPoint[1]);
...
window.frames[qid].onPointerEnter(pid);
lastHoverPoint = [qid, pid];

完成数据的导入和导出

此处思路借鉴自 https://github.com/soulteary/docker-nomnoml ,我们可以通过 base64 + JSON 序列化和反序列化来完成数据的导入和导出。并可以考虑将数据附加在 URL 中,达到无需接口支持的状态下,即可进行随意的分享,如果你乐意的话,还可以在浏览器端将配置输出为文件并进行自动保存。

基础实现逻辑也非常简单,只需要二十来行代码。

switch (CMD) {
    case 'save':
        var data = []
        for (var i = 0; i < window.frames.length; i++) {
            data.push(window.frames[i].dataExport()[1]);
        }
        return btoa(JSON.stringify(data));
        break;
    case 'load':
        var userData = dataImport.val().trim();
        if (!userData) return;
        try {
            userData = JSON.parse(atob(userData));
        } catch (_) {
            return;
        }
        [2, 1, 3, 4].forEach(function (qId, idx) {
            window.frames[idx].dataImport(userData[idx], qId);
        });
        break;
}

最后

授人以鱼不如授人以渔,所以这次就不上传代码啦。

本文中提到的,包含技术雷达基础功能的 Demo ,已上传至 https://github.com/soulteary/tech-radar-demo,感兴趣可以自取。

–EOF