【视觉高级篇】27 # 如何实现简单的3D可视化图表:GitHub贡献图表的3D可视化?

简介: 【视觉高级篇】27 # 如何实现简单的3D可视化图表:GitHub贡献图表的3D可视化?

说明

【跟月影学可视化】学习笔记。



第一步:准备要展现的数据

可以使用这个生成数据:https://github.com/sallar/github-contributions-api

f565dd7dfbd049fd85dd4fb509214d79.png


这里直接使用月影大佬的github提交数据的数据即可

ea8674b5adfb4deb9d3acef265ee211d.png


结构大致如下:


28a9339a97e549a4a9a82b929d4b3507.png



第二步:用 SpriteJS 渲染数据、完成绘图

SpriteJS v3


SpriteJS 是跨平台的高性能图形系统,它能够支持web、node、桌面应用和小程序的图形绘制和实现各种动画效果。


特性:


   像操作DOM对象一样操作画布上的图形元素

   WebGL2渲染

   多图层处理图形、文本、图像渲染

   DOM事件代理、自定义事件派发

   使用ES6+语法和面向对象编程

   OffscreenCanvas和Web Worker多线程渲染

   结构化对象树,对d3引擎友好,能够无缝使用

   服务端渲染

   Vue


注意:需要加入 3d 扩展库加载并渲染3D模型。


569c80ad50194bd59db6174bc6d46c4b.png


SpriteJS 的 3D 部分,它是基于 OGL 库实现的。SpriteJS 在 OGL 的基础上,对几何体元素进行了类似 DOM 元素的封装。这样创建几何体元素就可以像操作 DOM 一样方便,可以直接用 d3 库的 selection 子模块来进行操作。



1. 创建 Scene 对象


const container = document.getElementById('stage');
// 创建 Scene 对象
const scene = new Scene({
  container,
  displayRatio: 2,
});



2. 创建 Layer 对象

在 SpriteJS 中,一个 Layer 对象就对应于一个 Canvas 画布。

// 创建 Layer 对象:创建了一个 3D(WebGL)上下文的 Canvas 画布
const layer = scene.layer3d('fglayer', {
  ambientColor: [0.5, 0.5, 0.5, 1],
  camera: {
      fov: 35, // 相机的视角设置为 35 度
  },
});
// 相机坐标位置为(6, 6, 6)
layer.camera.attributes.pos = [6, 6, 6];
// 相机朝向坐标原点
layer.camera.lookAt([0, 0, 0]);


3. 将数据转换成柱状元素

这里借助 d3-selection,d3 是一个数据驱动文档的模型,d3-selection 能够通过数据操作文档树,添加元素节点。

https://github.com/d3/d3/blob/main/API.md

<!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>如何实现简单的3D可视化图表:GitHub 贡献图表的3D可视化</title>
        <style>
            #stage {
                width: 840px;
                height: 640px;
                border: 1px dashed #fa8072;
            }
        </style>
    </head>
    <body>
        <script src="https://d3js.org/d3.v5.js"></script>
        <div id="stage"></div>
        <script type="module">
            import { Scene } from 'https://unpkg.com/spritejs/dist/spritejs.esm.js';
            import { Cube, Light, shaders } from 'https://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.esm.js';
            // 获取该日期之前大约一年的数据
            let cache = null;
            async function getData(toDate = new Date()) {
                if(!cache) {
                    // 先从 JSON 文件中读取数据并缓存起来
                    const data = await (await fetch('./assets/github_contributions_akira-cn.json')).json();
                    cache = data.contributions.map((o) => {
                        o.date = new Date(o.date.replace(/-/g, '/'));
                        return o;
                    });
                }
                // 要拿到 toData 日期之前大约一年的数据(52周)
                let start = 0,
                    end = cache.length;
                // 用二分法查找
                while(start < end - 1) {
                    const mid = Math.floor(0.5 * (start + end));
                    const {date} = cache[mid];
                    if(date <= toDate) end = mid;
                    else start = mid;
                }
                // 获得对应的一年左右的数据
                let day;
                if(end >= cache.length) {
                    day = toDate.getDay();
                } else {
                    const lastItem = cache[end];
                    day = lastItem.date.getDay();
                }
                // 根据当前星期几,再往前拿52周的数据
                const len = 7 * 52 + day + 1;
                const ret = cache.slice(end, end + len);
                if(ret.length < len) {
                    // 日期超过了数据范围,补齐数据
                    const pad = new Array(len - ret.length).fill({count: 0, color: '#ebedf0'});
                    ret.push(...pad);
                }
                return ret;
            }
            (async function () {
                const container = document.getElementById('stage');
                // 创建 Scene 对象
                const scene = new Scene({
                    container,
                    displayRatio: 2, // 设置显示分辨率
                });
                // 创建 Layer 对象:创建了一个 3D(WebGL)上下文的 Canvas 画布
                const layer = scene.layer3d('fglayer', {
                    ambientColor: [0.5, 0.5, 0.5, 1],
                    camera: {
                        fov: 35, // 相机的视角设置为 35 度
                    },
                });
                // 相机坐标位置为(6, 6, 6)
                layer.camera.attributes.pos = [6, 6, 6];
                // 相机朝向坐标原点
                layer.camera.lookAt([0, 0, 0]);
                // 创建用来 3D 展示的 WebGL 程序:shaders.GEOMETRY 默认支持 phong 反射模型的一组着色器
                const program = layer.createProgram({
                    vertex: shaders.GEOMETRY.vertex,
                    fragment: shaders.GEOMETRY.fragment,
                });
                // 获取数据
                const dataset = await getData(new Date(2019, 12, 31));
                const max = d3.max(dataset, (a) => {
                    return a.count;
                });
                // 用数据来操作文档树
                const selection = d3.select(layer);
                /**
                 * 设置长方体 Cube 的属性
                 *      长 (width)
                 *      宽 (depth)
                 *      高 (height)
                 *      y 轴的缩放 (scaleY):设置为 d.count 与 max 的比值,值在 0~1 之间
                 *      位置 (pos)坐标:根据数据的索引设置 x 和 z 来决定的。
                 *      长方体的颜色 (colors)
                 * */
                const chart = selection.selectAll('cube')
                    .data(dataset)
                    .enter()
                    .append(() => {
                        return new Cube(program);
                    })
                    .attr('width', 0.14)
                    .attr('depth', 0.14)
                    .attr('height', 1)
                    .attr('scaleY', (d) => {
                        // max 是指一年的提交记录中,提交代码最多那天的数值。
                        return d.count / max;
                    })
                    .attr('pos', (d, i) => {
                        const x0 = -3.8 + 0.0717 + 0.0015;
                        const z0 = -0.5 + 0.05 + 0.0015;
                        const x = x0 + 0.143 * Math.floor(i / 7);
                        const z = z0 + 0.143 * (i % 7);
                        return [x, 0.5 * d.count /max, z];
                    })
                    .attr('colors', (d, i) => {
                        return d.color;
                    });
                layer.setOrbit();
            }());
        </script>
    </body>
</html>


效果如下:

image.gif



第三步:补充细节,实现更好的视觉效果

  1. 给柱状图添加光照
  2. 给柱状图增加一个底座
  3. 增加一个过渡动画,让柱状图的高度从不显示,到慢慢显示出来。
<!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>如何实现简单的3D可视化图表:GitHub 贡献图表的3D可视化</title>
        <style>
            #stage {
                width: 840px;
                height: 640px;
                border: 1px dashed #fa8072;
            }
        </style>
    </head>
    <body>
        <script src="https://d3js.org/d3.v5.js"></script>
        <div id="stage"></div>
        <script type="module">
            import { Scene } from 'https://unpkg.com/spritejs/dist/spritejs.esm.js';
            import { Cube, Light, shaders } from 'https://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.esm.js';
            // 获取该日期之前大约一年的数据
            let cache = null;
            async function getData(toDate = new Date()) {
                if(!cache) {
                    // 先从 JSON 文件中读取数据并缓存起来
                    const data = await (await fetch('./assets/github_contributions_akira-cn.json')).json();
                    cache = data.contributions.map((o) => {
                        o.date = new Date(o.date.replace(/-/g, '/'));
                        return o;
                    });
                }
                // 要拿到 toData 日期之前大约一年的数据(52周)
                let start = 0,
                    end = cache.length;
                // 用二分法查找
                while(start < end - 1) {
                    const mid = Math.floor(0.5 * (start + end));
                    const {date} = cache[mid];
                    if(date <= toDate) end = mid;
                    else start = mid;
                }
                // 获得对应的一年左右的数据
                let day;
                if(end >= cache.length) {
                    day = toDate.getDay();
                } else {
                    const lastItem = cache[end];
                    day = lastItem.date.getDay();
                }
                // 根据当前星期几,再往前拿52周的数据
                const len = 7 * 52 + day + 1;
                const ret = cache.slice(end, end + len);
                if(ret.length < len) {
                    // 日期超过了数据范围,补齐数据
                    const pad = new Array(len - ret.length).fill({count: 0, color: '#ebedf0'});
                    ret.push(...pad);
                }
                return ret;
            }
            (async function () {
                const container = document.getElementById('stage');
                // 创建 Scene 对象
                const scene = new Scene({
                    container,
                    displayRatio: 2, // 设置显示分辨率
                });
                // 创建 Layer 对象:创建了一个 3D(WebGL)上下文的 Canvas 画布
                const layer = scene.layer3d('fglayer', {
                    ambientColor: [0.5, 0.5, 0.5, 1], // 环境光
                    camera: {
                        fov: 35, // 相机的视角设置为 35 度
                    },
                });
                // 相机坐标位置为(6, 6, 6)
                layer.camera.attributes.pos = [6, 6, 6];
                // 相机朝向坐标原点
                layer.camera.lookAt([0, 0, 0]);
                // 添加一道白色的平行光,方向是 (-3, -3, -1)
                const light = new Light({
                    direction: [-3, -3, -1],
                    color: [1, 1, 1, 1]
                });
                layer.addLight(light);
                // 创建用来 3D 展示的 WebGL 程序:shaders.GEOMETRY 默认支持 phong 反射模型的一组着色器
                const program = layer.createProgram({
                    vertex: shaders.GEOMETRY.vertex,
                    fragment: shaders.GEOMETRY.fragment,
                });
                // 获取数据
                const dataset = await getData(new Date(2019, 12, 31));
                const max = d3.max(dataset, (a) => {
                    return a.count;
                });
                // 用数据来操作文档树
                const selection = d3.select(layer);
                /**
                 * 设置长方体 Cube 的属性
                 *      长 (width)
                 *      宽 (depth)
                 *      高 (height)
                 *      y 轴的缩放 (scaleY):设置为 d.count 与 max 的比值,值在 0~1 之间
                 *      位置 (pos)坐标:根据数据的索引设置 x 和 z 来决定的。
                 *      长方体的颜色 (colors)
                 * */
                const chart = selection.selectAll('cube')
                    .data(dataset)
                    .enter()
                    .append(() => {
                        return new Cube(program);
                    })
                    .attr('width', 0.14)
                    .attr('depth', 0.14)
                    .attr('height', 1)
                    .attr('scaleY', 0.001)
                    // .attr('scaleY', (d) => {
                    //     // max 是指一年的提交记录中,提交代码最多那天的数值。
                    //     return d.count / max;
                    // })
                    .attr('pos', (d, i) => {
                        const x0 = -3.8 + 0.0717 + 0.0015;
                        const z0 = -0.5 + 0.05 + 0.0015;
                        const x = x0 + 0.143 * Math.floor(i / 7);
                        const z = z0 + 0.143 * (i % 7);
                        // return [x, 0.5 * d.count /max, z];
                        return [x, 0, z];
                    })
                    .attr('colors', (d, i) => {
                        return d.color;
                    });
                layer.setOrbit();
                // 给柱状图增加一个底座
                const fragment = `
                    precision highp float;
                    precision highp int;
                    varying vec4 vColor;
                    varying vec2 vUv;
                    void main() {
                        float x = fract(vUv.x * 53.0);
                        float y = fract(vUv.y * 7.0);
                        x = smoothstep(0.0, 0.1, x) - smoothstep(0.9, 1.0, x);
                        y = smoothstep(0.0, 0.1, y) - smoothstep(0.9, 1.0, y);
                        gl_FragColor = vColor * (x + y);
                    }    
                `;
                const axisProgram = layer.createProgram({
                    vertex: shaders.TEXTURE.vertex,
                    fragment,
                });
                const ground = new Cube(axisProgram, {
                    width: 7.6,
                    height: 0.1,
                    y: -0.049, // not 0.05 to avoid z-fighting
                    depth: 1,
                    colors: 'rgba(0, 0, 0, 0.1)',
                });
                layer.append(ground);
                // 先把 scaleY 直接设为 0.001,然后用 d3.scaleLinear 来创建一个线性的缩放过程
                const linear = d3.scaleLinear()
                    .domain([0, max])
                    .range([0, 1.0]);
                // 最后通过 chart.trainsition 来实现这个线性动画
                chart.transition()
                    .duration(2000)
                    .attr('scaleY', (d, i) => {
                        return linear(d.count);
                    })
                    .attr('y', (d, i) => {
                        return 0.5 * linear(d.count);
                    });
            }());
        </script>
    </body>
</html>


效果如下

image.gif



什么是 z-fighting 现象?


在代码里有一处地方需要注意,这这里的 y 不能为 -0.05,我们写成 -0.049,少了 0.001 是为了让上层的柱状图稍微“嵌入”到底座里,从而避免因为底座上部和柱状图底部的 z 坐标一样,导致渲染的时候由于次序问题出现闪烁,这个问题在图形学里叫做 z-fighting。

c6e5fb5bd15c433eb5abfb10271780e6.png

如果是 y 为 -0.05,就会出现 z-fighting 现象,如下:


image.png


视觉高级篇知识脑图


31ea41767b3b44f7abad72b74ec44a40.png




目录
相关文章
|
8月前
|
分布式计算 DataWorks 数据可视化
Github实时数据分析与可视化
基于Github Archive公开数据集,将项目、行为等20+种事件类型数据实时采集至Hologres进行分析,并搭建可视化大屏。
436 0
|
9月前
|
存储 运维 安全
【运维知识高级篇】一篇文章带你搞懂GitHub基础操作!(注册用户+配置ssh-key+创建项目+创建存储库+拉取代码到本地+推送新代码到Github)
【运维知识高级篇】一篇文章带你搞懂GitHub基础操作!(注册用户+配置ssh-key+创建项目+创建存储库+拉取代码到本地+推送新代码到Github)
144 0
|
23天前
|
定位技术
github高级搜索技巧
github高级搜索技巧
38 0
|
6月前
|
消息中间件 设计模式 分布式计算
大厂招聘重点全在这!GitHub置顶Java基础-高级面试库+自学路线
最近几年经常会听见这样一种声音:“程序员是吃青春饭的,年龄一大就不吃香了”,在当下这种互联网产业增速放缓,甚至隐约展现出疲态的时刻,此类言论就很有市场。
|
8月前
|
SQL 分布式计算 数据可视化
课时1:Github实时数据分析与可视化(二)
课时1:Github实时数据分析与可视化
130 0
|
8月前
|
数据可视化 关系型数据库 MySQL
课时1:Github实时数据分析与可视化
课时1:Github实时数据分析与可视化
227 0
|
10月前
|
Rust NoSQL 数据可视化
GitHub 上又一可视化低代码神器,诞生了!
项目源码地址:docs.qq.com/doc/DVHRQUVhKVkN2dUha
235 0
|
10月前
|
存储 分布式计算 DataWorks
Github实时数据分析与可视化训练营火热开启!免费领取5000元云上资源
此次训练营内容基于GitHub Archive公开数据集,通过DataWorks将GitHub中的项目、行为等20多种事件类型数据实时采集至Hologres进行分析,同时使用DataV内置模板,快速搭建实时可视化数据大屏,从开发者、项目、编程语言等多个维度了解GitHub实时数据变化情况。
|
11月前
|
缓存 NoSQL Redis
顶级理解!阿里这份Github星标63.7K的Redis高级笔记简直不要太细
大家都知道Redis的业务范围是非常广的,但是对于刚入行的小伙伴来说可能也就知道个缓存跟分布式锁。因为Redis的很多功能在一些小企业里,根本是用不到的,得等到并发量到了一定的程度,系统扛不住了,才会用到Redis那些高级的功能。下面LZ就带大家来看看,Redis到底能干些啥:
|
11月前
|
XML SpringCloudAlibaba Java
“阿里爸爸”又爆新作!Github新开源303页Spring全家桶高级笔记
Spring全家桶 不知道各位Java好大哥们闲的时候会不会去关注Spring目前的官网,你会发现他的slogan是: Spring makes Java Simple。它让Java的开发变得更加简单。某种意义上来说:是Spring成就了Java!但随之而来的就是:由他之后诞生出来的各种组件;SpringBoot,SpringCloud,SpringSecurity啥的都成了我们Java程序员必须要掌握的技能;每次面试也都是必问。