使用SVG绘制3D柱状图(纯JS)

1,803 阅读2分钟

点击下方查看我在实际场景中的应用效果:

CatsJuice/ssr-contributions-img: Server side rendering of Github contribution wall API

绘制基础立方体

首先,我们需要绘制一个最简单的立方体:

Basic Cube.jpg

把这个图形看作是在笛卡尔坐标系(或者说是直角坐标系)中的3个几何形状的组合,看起来就像下图一样,需要注意的是,在svg的坐标系中,y轴的正方向是向下的:

Cartesian coordinate system.jpg

我们需要定义一下各个顶点和面的基础概念,以便在后面做区分。这里 O 作为立方体的原点:

Basic concepts.jpg

而视角的高或低是由顶部的面决定的,我们需要通过定义顶部图形的两条对角线的长度来修改形状和视角(两条对角线恰好是垂直,并且分别和 x轴 、 y轴 平行)

SizeXY.jpg

假设原点 O 的坐标点(在直角坐标系中)是 O(ox, oy),那么我们就可以计算出全部顶点的坐标:

Calc.jpg

一切就绪,我们先将这些定义转换为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) (我们假设是从左下角这个方向看过去的):

row_col.jpg

如果从网格 [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);