Pixi.js 实现 🔥 热力图
技术栈:
React v18+Pixi.js 7.2.0-beta+Pixi.js-legacy 7.2.0-beta
相关文章:
前言
现有可视化库中,很少看到有实现了行累加或列累加功能的热力图,基于这个需求我手动实现了一个简单的可视化热力效果图,供学习参考 🥳。
效果图
代码拆分讲解
绘制热力图格网
// 调用 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],
}}
/>