说明
【跟月影学可视化】学习笔记。
第一步:准备要展现的数据
可以使用这个生成数据:https://github.com/sallar/github-contributions-api
这里直接使用月影大佬的github提交数据的数据即可
结构大致如下:
第二步:用 SpriteJS 渲染数据、完成绘图
SpriteJS 是跨平台的高性能图形系统,它能够支持web、node、桌面应用和小程序的图形绘制和实现各种动画效果。
特性:
像操作DOM对象一样操作画布上的图形元素
WebGL2渲染
多图层处理图形、文本、图像渲染
DOM事件代理、自定义事件派发
使用ES6+语法和面向对象编程
OffscreenCanvas和Web Worker多线程渲染
结构化对象树,对d3引擎友好,能够无缝使用
服务端渲染
Vue
注意:需要加入 3d 扩展库加载并渲染3D模型。
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>
效果如下:
第三步:补充细节,实现更好的视觉效果
- 给柱状图添加光照
- 给柱状图增加一个底座
- 增加一个过渡动画,让柱状图的高度从不显示,到慢慢显示出来。
<!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>
效果如下
什么是 z-fighting 现象?
在代码里有一处地方需要注意,这这里的 y 不能为 -0.05,我们写成 -0.049,少了 0.001 是为了让上层的柱状图稍微“嵌入”到底座里,从而避免因为底座上部和柱状图底部的 z 坐标一样,导致渲染的时候由于次序问题出现闪烁,这个问题在图形学里叫做 z-fighting。
如果是 y 为 -0.05
,就会出现 z-fighting
现象,如下:
视觉高级篇知识脑图