点击下方查看我在实际场景中的应用效果:
CatsJuice/ssr-contributions-img: Server side rendering of Github contribution wall API
绘制基础立方体
首先,我们需要绘制一个最简单的立方体:
把这个图形看作是在笛卡尔坐标系(或者说是直角坐标系)中的3个几何形状的组合,看起来就像下图一样,需要注意的是,在svg的坐标系中,y轴的正方向是向下的:
我们需要定义一下各个顶点和面的基础概念,以便在后面做区分。这里 O 作为立方体的原点:
而视角的高或低是由顶部的面决定的,我们需要通过定义顶部图形的两条对角线的长度来修改形状和视角(两条对角线恰好是垂直,并且分别和 x轴 、 y轴 平行)
假设原点 O 的坐标点(在直角坐标系中)是 O(ox, oy),那么我们就可以计算出全部顶点的坐标:
一切就绪,我们先将这些定义转换为js变量: (直接在一个html文件的 <script></script> 标签中编写即可):
const sizeX = 20;
const ratio = 2;
const sizeY = sizeX / ratio;
const height = 40;const ox = 100;
const oy = 100;
const pA = [ox - sizeX, oy + sizeY];
const pB = [ox, oy + 2 * sizeY];
const pC = [ox + sizeX, oy + sizeY];
const pD = [ox - sizeX, oy - height + sizeY];
const pE = [ox, oy - height + 2 * sizeY];
const pF = [ox + sizeX, oy - height + sizeY];
const pG = [ox, oy - height];
const pO = [ox, oy];
const face_l = [pE, pD, pA, pB];
const face_r = [pE, pF, pC, pB];
const face_t = [pE, pD, pG, pF];
为了方便我们编写svg的path属性,我们将生成路径点的逻辑写成一个函数: d():
/**
* @params {Array<Array<number>>} points
*/
function d(points) {
// 拼接上第一个点,以确保图形闭合(绘制stroke时有必要
const raw = [...points, points[0]]
.map((point) => `${point[0]} ${point[1]}`)
.join(' ');
return "M" + raw;
}
然后直接写一个 svg 字符,并把它展示到页面中:
const svg = `<svg width="200" height="200">
<path d="${d(face_l)}" stroke="#000" fill="transparent" />
<path d="${d(face_r)}" stroke="#000" fill="transparent" />
<path d="${d(face_t)}" stroke="#000" fill="transparent" />
</svg>`;
document.write(svg);
按网格的形式绘制
首先需要定义网格的 行数(rowNum) 和 列数(colNum) (我们假设是从左下角这个方向看过去的):
如果从网格 [0, 0] 开始绘制,放入 svg, 那么左侧的将会在svg外面,所以我们需要将整体做个水平移动,并假设柱状图最大高度为 maxBarHeight = 100。
那么这样的一个倾斜网格,至少需要的svg的宽和高为:
svgWidth = sizeX * (rowNum + colNum),
svgHeight = sizeY * (rowNum + colNum) + maxBarHeight
整个网格需要水平移动: rowNum * sizeX, 垂直移动: maxBarHeight
然后我们将画基础立方体的逻辑封装为 function cube(ox, oy, height), 和上面不一样的是,立方体的原点和高度需要通过参数传递,并且最后生成的是一个 <g></g>组合:
/**
* create a cube
*/
function cube(ox, oy, height) {
const pO = [ox, oy];
const pA = [ox - sizeX, oy + sizeY];
const pB = [ox, oy + 2 * sizeY];
const pC = [ox + sizeX, oy + sizeY];
const pD = [ox - sizeX, oy - height + sizeY];
const pE = [ox, oy - height + 2 * sizeY];
const pF = [ox + sizeX, oy - height + sizeY];
const pG = [ox, oy - height];
const face_l = [pE, pD, pA, pB];
const face_r = [pE, pF, pC, pB];
const face_t = [pE, pD, pG, pF];
return `<g>
<path d="${d(face_l)}" stroke="#333" fill="#777" />
<path d="${d(face_r)}" stroke="#333" fill="#999" />
<path d="${d(face_t)}" stroke="#333" fill="#bbb" />
</g>`;
}
最重要的是,我们需要把 第几行、 第几列 的立方体的原点位置做映射, 定义一个方法:
/**
* transform the cube index to pixel
*/
function coordIndex(rowIndex, colIndex) {
return [
(rowIndex - colIndex) * (sizeX + gap),
(rowIndex + colIndex) * (sizeY + gap)
];
}
随机生成一些数据:
// create radom data
const rowNum = 4;
const colNum = 6;
const cubes = [];
for (let i = 0; i < colNum; i++) {
for (let j = 0; j < rowNum; j++) {
cubes.push([
i,
j,
Math.floor(Math.random() * maxBarHeight)
]);
}
}
生成最终的 svg 代码:
// render
const svg = `<svg width="${svgWidth}" height="${svgHeight}">
<g transform="translate(${translateX}, ${translateY})">
${cubes.map((opt) => cube(
...coordIndex(...opt), ...opt.slice(2)
)).join('')}
</g>
</svg>`;
在Codepen 中查看
Svg cubes in grid (codepen.io)
全部的代码
// variables
const ratio = 2;
const sizeX = 18;
const sizeY = sizeX / ratio;
const gap = 1;
const colNum = 20; // num of cols
const rowNum = 4; // num of rows
const brightness = 20;
const maxBarHeight = 50;
const svgWidth = (sizeX + gap) * (colNum + rowNum);
const svgHeight = (sizeY + gap) * (colNum + rowNum) + maxBarHeight;
const translateX = rowNum * (sizeX + gap);
const translateY = maxBarHeight;
/**
* create path
*/
function d(points) {
const raw = [...points, points[0]]
.map((point) => `${point[0]} ${point[1]}`)
.join(' ');
return `M${raw}`;
}
/**
* transform the cube index to pixel
*/
function coordIndex(rowIndex, colIndex) {
return [
(rowIndex - colIndex) * (sizeX + gap) ,
(rowIndex + colIndex) * (sizeY + gap)
];
}
/**
* create a cube
*/
function cube(ox, oy, height, fill, stroke) {
const pO = [ox, oy];
const pA = [ox - sizeX, oy + sizeY];
const pB = [ox, oy + 2 * sizeY];
const pC = [ox + sizeX, oy + sizeY];
const pD = [ox - sizeX, oy - height + sizeY];
const pE = [ox, oy - height + 2 * sizeY];
const pF = [ox + sizeX, oy - height + sizeY];
const pG = [ox, oy - height];
const face_l = [pE, pD, pA, pB];
const face_r = [pE, pF, pC, pB];
const face_t = [pE, pD, pG, pF];
return `<g>
<path d="${d(face_l)}" stroke="${stroke}" fill="${shadeColor(fill, -(brightness + 15))}" />
<path d="${d(face_r)}" stroke="${stroke}" fill="${shadeColor(fill, -(5 + brightness))}" />
<path d="${d(face_t)}" stroke="${stroke}" fill="${shadeColor(fill, brightness - 10)}" />
</g>`;
}
function shadeColor(color, percent) {
let R = parseInt(color.substring(1, 3), 16);
let G = parseInt(color.substring(3, 5), 16);
let B = parseInt(color.substring(5, 7), 16);
R = parseInt('' + (R * (100 + percent)) / 100);
G = parseInt('' + (G * (100 + percent)) / 100);
B = parseInt('' + (B * (100 + percent)) / 100);
R = R < 255 ? R : 255;
G = G < 255 ? G : 255;
B = B < 255 ? B : 255;
const RR = R.toString(16).length == 1 ? '0' + R.toString(16) : R.toString(16);
const GG = G.toString(16).length == 1 ? '0' + G.toString(16) : G.toString(16);
const BB = B.toString(16).length == 1 ? '0' + B.toString(16) : B.toString(16);
return '#' + RR + GG + BB;
}
function radomColor() {
return Math.floor(180000 + Math.random()*1000).toString(16);
}
// create radom data
const cubes = [];
for (let i = 0; i < colNum; i++) {
for (let j = 0; j < rowNum; j++) {
cubes.push([
i,
j,
Math.floor(Math.random() * maxBarHeight),
radomColor(),
]);
}
}
// render
const svg = `<svg width="${svgWidth}" height="${svgHeight}">
<g transform="translate(${translateX}, ${translateY})">
${cubes.map((opt) => cube(
...coordIndex(...opt), ...opt.slice(2), '#333'
)).join('')}
</g>
</svg>`;
document.write(svg);