Pixi.js 实现一个 🔥 热力图

817 阅读2分钟

Pixi.js 实现 🔥 热力图

技术栈: React v18 + Pixi.js 7.2.0-beta + Pixi.js-legacy 7.2.0-beta

相关文章:

前言

现有可视化库中,很少看到有实现了行累加或列累加功能的热力图,基于这个需求我手动实现了一个简单的可视化热力效果图,供学习参考 🥳。

效果图

heatmap.png

代码拆分讲解

绘制热力图格网

// 调用 PIXI.Graphics 实例绘制一个单元格
const drawCell = useCallback(
  (g: any, pos: [number, number], fillColor: string) => {
    g.lineStyle(1);
    g.beginFill(fillColor, 1);
    g.drawRect(pos[0], pos[1], options.cell.width, options.cell.height);
  },
  [options]
);
// 基于二维数组绘制热力格网
const draw = useCallback(
  (g: any) => {
    g.clear();
    for (let i = 0; i < common.rowsNum; i++) {
      for (let j = 0; j < common.colsNum; j++) {
        const value = data[i][j];
        // 计算每个单元格的位置坐标
        const [x, y] = [
          pixiOptions.paintOrigin[0],
          options.needTopBar
            ? pixiOptions.paintOrigin[1] + options.cell.height + 10
            : pixiOptions.paintOrigin[1],
        ];
        // 基于数值计算每个单元格的颜色值
        const color =
          colors[
            getColorRangeIndex(value, {
              minValue: common.minValue,
              maxValue: common.maxValue,
              splits: colors.length,
            })
          ];
        // 调用单元格绘制方法
        drawCell(
          g,
          [x + j * options.cell.width, y + i * options.cell.height],
          color
        );
      }
    }
    // 判断各列数据是否需要 sum to top
    if (options.needTopBar) {
      const [x, y] = [pixiOptions.paintOrigin[0], pixiOptions.paintOrigin[1]];
      const arr = [];
      for (let j = 0; j < common.colsNum; j++) {
        arr.push(_.sumBy(data, (o) => o[j]));
      }
      const min = _.min(arr)!;
      const max = _.max(arr)!;
      for (let j = 0; j < common.colsNum; j++) {
        const color =
          colors[
            getColorRangeIndex(arr[j], {
              minValue: min,
              maxValue: max,
              splits: colors.length,
            })
          ];
        drawCell(g, [x + j * options.cell.width, y], color);
      }
    }
    // 判断各行数据是否需要 sum to right
    if (options.needRightBar) {
      const [x, y] = [pixiOptions.paintOrigin[0], pixiOptions.paintOrigin[1]];
      const arr = [];
      for (let i = 0; i < common.rowsNum; i++) {
        arr.push(_.sum(data[i]));
      }
      const min = _.min(arr)!;
      const max = _.max(arr)!;
      for (let i = 0; i < common.rowsNum; i++) {
        const color =
          colors[
            getColorRangeIndex(arr[i], {
              minValue: min,
              maxValue: max,
              splits: colors.length,
            })
          ];
        drawCell(
          g,
          [
            x + common.colsNum * options.cell.width + 10,
            y + (options.needTopBar ? options.cell.height + 10 : 0) + i * options.cell.height,
          ],
          color
        );
      }
    }
    g.endFill();
  },
  [data, common, pixiOptions, options, colors, drawCell]
);

绘制 Axis 坐标轴标签

// 绘制坐标刻度
const drawAxisTexts = useCallback(
  (app: PIXI.Application<PIXI.ICanvas>) => {
    // 绘制 y 轴 label
    if (ys.length) {
      for (let i = 0; i < ys.length; i++) {
        const text = new PIXI.Text(ys[i], textStyle);
        // 设置文本定位相对的锚点位置
        text.anchor.set(1, 0);
        // 设置坐标
        text.x = pixiOptions.paintOrigin[0] - 10;
        text.y =
          pixiOptions.paintOrigin[1] +
          options.cell.height * (ys.length - i - 1) +
          (options.needTopBar ? options.cell.height + 10 : 0);
        app.stage.addChild(text);
      }
    }
    // 绘制 x 轴 label,同上
    if (xs.length) {
      for (let i = 0; i < xs.length; i++) {
        const text = new PIXI.Text(xs[i], textStyle);
        text.anchor.set(0.5, 0);
        text.x =
          pixiOptions.paintOrigin[0] +
          options.cell.width * i +
          options.cell.width / 2;
        text.y =
          pixiOptions.paintOrigin[1] +
          options.cell.height * ys.length +
          (options.needTopBar ? options.cell.height + 10 : 0) +
          10;
        app.stage.addChild(text);
      }
    }
  },
  [xs, ys, pixiOptions, options, textStyle]
);

绘制颜色条

// canvas 创建渐变条
function createGradient(width: number, height: number, colors: string[]) {
  // 初始化一个 canvas
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  // 获取 2d 上下文
  const ctx = canvas.getContext("2d");
  if (ctx) {
    // 创建渐变实例并添加颜色
    const grd = ctx.createLinearGradient(0, height, 0, 0);
    for (let i = 0; i <= colors.length - 1; i++) {
      grd.addColorStop(i / colors.length, colors[i]);
    }
    // 填充并绘制为矩形
    ctx.fillStyle = grd;
    ctx.fillRect(0, 0, width, height);
  }
  // 调用 PIXI.Texture.from(HTMLCanvasElement) 将 canvas 转为 pixi.js texture 对象
  return PIXI.Texture.from(canvas);
}

// 绘制图例
const drawColorBar = useCallback(
  (app: PIXI.Application<PIXI.ICanvas>) => {
    if (options.needLegend) {
      const height = options.cell.height * common.rowsNum;
      const width = options.cell.width;
      // 创建渐变条并添加到 sprite 精灵容器展示
      const grd = createGradient(width, height, colors);
      const sprite = new PIXI.Sprite(grd);
      // 设置定位锚点
      sprite.anchor.set(0, 1);
      // 设置坐标
      const pos = {
        y:
          pixiOptions.paintOrigin[1] +
          common.rowsNum * options.cell.height +
          (options.needTopBar ? options.cell.height + 10 : 0),
        x:
          pixiOptions.paintOrigin[0] +
          common.colsNum * options.cell.width +
          (options.needRightBar ? options.cell.width + 10 : 0) +
          10,
      };
      sprite.position.set(pos.x, pos.y);
      sprite.width = width;
      sprite.height = height;
      app.stage.addChild(sprite);

      // 设置最小值 label
      const minText = new PIXI.Text(
        common.minValue.toExponential(0),
        textStyle
      );
      minText.anchor.set(0.5, 0);
      minText.position.set(pos.x + options.cell.width / 2, pos.y + 10);
      app.stage.addChild(minText);

      // 设置最大值 label
      const maxText = new PIXI.Text(
        common.maxValue.toExponential(0),
        textStyle
      );
      maxText.anchor.set(0.5, 1);
      maxText.position.set(pos.x + options.cell.width / 2, pos.y - height - 5);
      app.stage.addChild(maxText);
    }
  },
  [options, common, colors, pixiOptions, textStyle]
);

完整代码

import React, { useCallback, useEffect, useMemo, useRef } from "react";
import * as PIXI from "pixi.js-legacy";
import _ from "lodash";
import colormap from "colormap";

// Types
type Options = {
  cell: {
    width: number;
    height: number;
  };
  fontSize?: number;
  needTopBar?: boolean;
  needRightBar?: boolean;
  needLegend?: boolean;
  padding?: [number, number, number, number]; // [top, right, bottom, left]
};
type HeatmapProps = {
  data: number[][];
  xs?: string[];
  ys?: string[];
  options: Options;
};

// Common Functions
function getColorRangeIndex(
  value: number,
  {
    minValue,
    maxValue,
    splits,
  }: { minValue: number; maxValue: number; splits: number }
) {
  const per = (maxValue - minValue) / (splits - 1);
  return Math.floor((value - minValue) / per);
}
function createGradient(width: number, height: number, colors: string[]) {
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext("2d");
  if (ctx) {
    const grd = ctx.createLinearGradient(0, height, 0, 0);
    for (let i = 0; i <= colors.length - 1; i++) {
      grd.addColorStop(i / colors.length, colors[i]);
    }
    ctx.fillStyle = grd;
    ctx.fillRect(0, 0, width, height);
  }
  return PIXI.Texture.from(canvas);
}

export default function Heatmap(props: HeatmapProps) {
  const { data, options, xs = [], ys = [] } = props;

  const common = useMemo(() => {
    const all = _.flattenDeep(data);
    return {
      rowsNum: data.length,
      colsNum: data[0].length,
      minValue: _.min(all)!,
      maxValue: _.max(all)!,
    };
  }, [data]);

  // 画布配置
  const pixiOptions = useMemo(() => {
    const defaultPadding = 20;
    return {
      stage: {
        width:
          (options.padding?.[3] ?? defaultPadding) +
          (options.padding?.[1] ?? defaultPadding) +
          (options.needRightBar
            ? (common.colsNum + 1) * options.cell.width + 10
            : common.colsNum * options.cell.width),
        height:
          (options.padding?.[0] ?? defaultPadding) +
          (options.padding?.[2] ?? defaultPadding) +
          (options.needTopBar
            ? (common.rowsNum + 1) * options.cell.height + 10
            : common.rowsNum * options.cell.height),
      },
      // 坐标原点
      paintOrigin: [
        options.padding?.[3] ?? defaultPadding,
        options.padding?.[0] ?? defaultPadding,
      ],
    };
  }, [common, options]);

  // 颜色棒
  const colors = useMemo(() => {
    return colormap({
      colormap: "summer",
      nshades: 100,
      format: "hex",
      alpha: 1,
    }).reverse();
  }, []);

  // pixi.js 字体样式
  const textStyle = useMemo(
    () =>
      new PIXI.TextStyle({
        fontFamily: "Arial",
        fontSize: options.fontSize ?? 10,
        // fontWeight: 'bold',
      }),
    [options]
  );

  // 绘制一个单元格
  const drawCell = useCallback(
    (g: any, pos: [number, number], fillColor: string) => {
      g.lineStyle(1);
      g.beginFill(fillColor, 1);
      g.drawRect(pos[0], pos[1], options.cell.width, options.cell.height);
    },
    [options]
  );
  // 绘制
  const draw = useCallback(
    (g: any) => {
      g.clear();
      for (let i = 0; i < common.rowsNum; i++) {
        for (let j = 0; j < common.colsNum; j++) {
          const value = data[i][j];
          const [x, y] = [
            pixiOptions.paintOrigin[0],
            options.needTopBar
              ? pixiOptions.paintOrigin[1] + options.cell.height + 10
              : pixiOptions.paintOrigin[1],
          ];
          const color =
            colors[
              getColorRangeIndex(value, {
                minValue: common.minValue,
                maxValue: common.maxValue,
                splits: colors.length,
              })
            ];
          drawCell(
            g,
            [x + j * options.cell.width, y + i * options.cell.height],
            color
          );
        }
      }
      if (options.needTopBar) {
        const [x, y] = [pixiOptions.paintOrigin[0], pixiOptions.paintOrigin[1]];
        const arr = [];
        for (let j = 0; j < common.colsNum; j++) {
          arr.push(_.sumBy(data, (o) => o[j]));
        }
        const min = _.min(arr)!;
        const max = _.max(arr)!;
        for (let j = 0; j < common.colsNum; j++) {
          const color =
            colors[
              getColorRangeIndex(arr[j], {
                minValue: min,
                maxValue: max,
                splits: colors.length,
              })
            ];
          drawCell(g, [x + j * options.cell.width, y], color);
        }
      }
      if (options.needRightBar) {
        const [x, y] = [pixiOptions.paintOrigin[0], pixiOptions.paintOrigin[1]];
        const arr = [];
        for (let i = 0; i < common.rowsNum; i++) {
          arr.push(_.sum(data[i]));
        }
        const min = _.min(arr)!;
        const max = _.max(arr)!;
        for (let i = 0; i < common.rowsNum; i++) {
          const color =
            colors[
              getColorRangeIndex(arr[i], {
                minValue: min,
                maxValue: max,
                splits: colors.length,
              })
            ];
          drawCell(
            g,
            [
              x + common.colsNum * options.cell.width + 10,
              y + (options.needTopBar ? options.cell.height + 10 : 0) + i * options.cell.height,
            ],
            color
          );
        }
      }
      g.endFill();
    },
    [data, common, pixiOptions, options, colors, drawCell]
  );
  // 绘制坐标刻度
  const drawAxisTexts = useCallback(
    (app: PIXI.Application<PIXI.ICanvas>) => {
      if (ys.length) {
        for (let i = 0; i < ys.length; i++) {
          const text = new PIXI.Text(ys[i], textStyle);
          text.anchor.set(1, 0);
          text.x = pixiOptions.paintOrigin[0] - 10;
          text.y =
            pixiOptions.paintOrigin[1] +
            options.cell.height * (ys.length - i - 1) +
            (options.needTopBar ? options.cell.height + 10 : 0);
          app.stage.addChild(text);
        }
      }
      if (xs.length) {
        for (let i = 0; i < xs.length; i++) {
          const text = new PIXI.Text(xs[i], textStyle);
          text.anchor.set(0.5, 0);
          text.x =
            pixiOptions.paintOrigin[0] +
            options.cell.width * i +
            options.cell.width / 2;
          text.y =
            pixiOptions.paintOrigin[1] +
            options.cell.height * ys.length +
            (options.needTopBar ? options.cell.height + 10 : 0) +
            10;
          app.stage.addChild(text);
        }
      }
    },
    [xs, ys, pixiOptions, options, textStyle]
  );

  // 绘制图例
  const drawColorBar = useCallback(
    (app: PIXI.Application<PIXI.ICanvas>) => {
      if (options.needLegend) {
        const height = options.cell.height * common.rowsNum;
        const width = options.cell.width;
        const grd = createGradient(width, height, colors);
        const sprite = new PIXI.Sprite(grd);
        sprite.anchor.set(0, 1);
        const pos = {
          y:
            pixiOptions.paintOrigin[1] +
            common.rowsNum * options.cell.height +
            (options.needTopBar ? options.cell.height + 10 : 0),
          x:
            pixiOptions.paintOrigin[0] +
            common.colsNum * options.cell.width +
            (options.needRightBar ? options.cell.width + 10 : 0) +
            10,
        };
        sprite.position.set(pos.x, pos.y);
        sprite.width = width;
        sprite.height = height;
        app.stage.addChild(sprite);

        const minText = new PIXI.Text(common.minValue.toExponential(0), textStyle);
        minText.anchor.set(0.5, 0);
        minText.position.set(pos.x + options.cell.width / 2, pos.y + 10);
        app.stage.addChild(minText)

        const maxText = new PIXI.Text(common.maxValue.toExponential(0), textStyle);
        maxText.anchor.set(0.5, 1);
        maxText.position.set(pos.x + options.cell.width / 2, pos.y - height - 5);
        app.stage.addChild(maxText)
      }
    },
    [options, common, colors, pixiOptions, textStyle]
  );

  const ref = useRef<HTMLDivElement>(null!);
  useEffect(() => {
    const app = new PIXI.Application({
      width: pixiOptions.stage.width,
      height: pixiOptions.stage.height,
      backgroundColor: 0xffffff,
      // preserveDrawingBuffer: true, // 开启 webgl 缓冲区保存
      forceCanvas: true, // only available in pixi.js-legacy
      resolution: 2,
      autoDensity: true,
    });

    const graphic = new PIXI.Graphics();
    draw(graphic);
    app.stage.addChild(graphic);

    drawAxisTexts(app);
    drawColorBar(app);

    ref.current.appendChild(app.view as any);

    return () => {
      app.destroy(true, true);
    };
  }, []);

  return <div ref={ref}></div>;
}

使用示例

Data For Example (2D-Array)

0.16980247,0.16366622,0.15743771,0.15166266,0.14571382,0.13954464,0.13311021,0.12614505,0.118344195,0.109423,0.098513566,0.0852332,0.06723933,0.041528545
2.446079e-06,0.0,0.0,3.6242032e-05,3.9466373e-05,0.0,0.0,0.0,0.0,0.0,1.6120064e-06,0.0,0.0,0.0
3.270644e-06,0.0,0.0,5.034445e-05,5.555617e-05,0.0,0.0,0.0,0.0,0.0,2.5745967e-06,0.0,0.0,0.0
0.38968024,0.3896869,0.38977,0.38974884,0.38980606,0.38993266,0.38999817,0.39006913,0.39014667,0.39023948,0.39034694,0.39048585,0.39064878,0.39090443
0.4405115,0.44664684,0.4527923,0.45850188,0.46438512,0.4705227,0.4768916,0.4837858,0.49150917,0.50033754,0.5111353,0.52428097,0.5421119,0.56756705
<Heatmap
  data={data}
  xs={Array.from({length: (data)[0].length}, (v, i) => (String(i + 1 + data.length)))}
  ys={Array.from({length: data.length}, (v, i) => (String(i + 1)))}
  options={{
    cell: { width: 15, height: 15 },
    needTopBar: true,
    needRightBar: true,
    needLegend: true,
    padding: [20, 40, 20, 20],
  }}
/>