jtwang7 / Project-Note

开发小记 - 项目里的一些新奇玩意儿
1 stars 0 forks source link

数据可视化 - 🔥 热力图 ( Link ) #15

Open jtwang7 opened 1 year ago

jtwang7 commented 1 year ago

数据可视化 - 🔥 关系热力图 ( Link )

简介

基于 canvas 2D (fabric.js 库) 实现的热力图可视化效果。相较于传统热力图实现,在此基础上添加了内部 link 连接。 实现功能:

可视化效果

关系热力图

代码

🌈 index.tsx

import { useEffect, useRef } from "react";
import { fabric } from "fabric";
import { heatmapBar1 } from "@/pages/analysis/constants/color";
import _ from "lodash";
import { ArrayProperty, RelationHeatmapProps } from "./types";

export default function RelationHeatmap(props: RelationHeatmapProps) {
  const {
    cell,
    arrow,
    label,
    node,
    data = [],
    links = [],
    colorBar = heatmapBar1,
    width = 200,
    height = 200,
  } = props;

  const canvasRef = useRef<HTMLCanvasElement>(null!);
  const canvasInstance = useRef<fabric.Canvas | null>(null);

  useEffect(() => {
    canvasInstance.current = new fabric.Canvas(canvasRef.current, {});
    return () => {
      canvasInstance.current?.dispose();
      canvasInstance.current = null;
    };
  }, []);

  // 拆分数组类型默认值
  const toX = (value?: ArrayProperty, defaultValue = 0) => {
    return value ? (Array.isArray(value) ? value[0] : value) : defaultValue;
  };
  const toY = (value?: ArrayProperty, defaultValue = 0) => {
    return value ? (Array.isArray(value) ? value[1] : value) : defaultValue;
  };

  // 颜色映射
  const toColor = (min = 0, max = Infinity, colors = colorBar) => {
    const step = (max - min) / colors.length;
    let range: number[] = [];
    for (let i = 0; i < colors.length; i++) {
      range.push(i * step);
    }
    return (value: number) => {
      const idx = _.findLastIndex(range, (item) => value > item);
      return idx === -1 ? "#fff" : colors[idx];
    };
  };

  // 统一配置项
  const commonOption: fabric.IObjectOptions = {
    hasBorders: false,
    hasControls: false,
    lockMovementX: true,
    lockMovementY: true,
    hoverCursor: "default",
  };

  const cellWidth = toX(cell?.size, 20);
  const cellHeight = toY(cell?.size, 20);
  function drawRect({
    left = 0,
    top = 0,
    fill = cell?.fill ?? "#fff",
  }: {
    left?: number;
    top?: number;
    fill?: string;
  }) {
    const rect = new fabric.Rect({
      ...commonOption,
      originX: "center",
      originY: "center",
      rx: toX(cell?.r, 0),
      ry: toY(cell?.r, 0),
      opacity: cell?.opacity ?? 1,
      scaleX: toX(cell?.scale, 0.8),
      scaleY: toY(cell?.scale, 0.8),
      stroke: cell?.stroke ?? "#111",
      strokeWidth: cell?.strokeWidth ?? 1,
      strokeDashArray: cell?.strokeDashArray,
      width: cellWidth,
      height: cellHeight,
      left,
      top,
      fill,
      hoverCursor: "pointer",
      // 禁止选择:启用会导致点击对象时,对象自动移至最上层
      selectable: false,
    });
    return rect;
  }

  function drawArrow(start: [number, number], end: [number, number]) {
    const deltaX = end[0] - start[0];
    const deltaY = end[1] - start[1];
    const angle = Math.atan(deltaY / deltaX) * (180 / Math.PI);
    const line = new fabric.Line([start[0], start[1], end[0], end[1]], {
      originX: "center",
      originY: "center",
      stroke: arrow?.stroke ?? "#111",
      strokeWidth: arrow?.lineWidth ?? 1,
    });
    const triangle = new fabric.Triangle({
      width: arrow?.width ?? 6,
      height: arrow?.height ?? 8,
      angle: 90 + angle,
      originX: "center",
      originY: "top",
      stroke: arrow?.stroke ?? "#111",
      fill: arrow?.stroke ?? "#111",
      left: end[0],
      top: end[1],
    });
    const group = new fabric.Group([line, triangle], {
      ...commonOption,
    });
    return group;
  }

  function drawCircle(left: number, top: number) {
    const circle = new fabric.Circle({
      left: left,
      top: top,
      fill: node?.fill ?? "#fff",
      stroke: node?.stroke ?? "#111",
      strokeWidth: node?.strokeWidth ?? 1,
      opacity: node?.opacity ?? 1,
      originX: "center",
      originY: "center",
      radius: _.min([cellWidth, cellHeight])! / 2,
      scaleX: toX(cell?.scale, 0.8) - 0.3,
      scaleY: toY(cell?.scale, 0.8) - 0.3,
    });
    return circle;
  }

  function drawText(
    content: string,
    { left, top }: { left: number; top: number }
  ) {
    return new fabric.Text(content, {
      left,
      top,
      originX: "center",
      originY: "center",
      fontSize: label?.fontSize ?? 15,
    });
  }

  useEffect(() => {
    let canvas: fabric.Canvas | null = canvasInstance.current;
    if (canvas && data.length) {
      const xs = _.uniq(data.map((item) => item.x));
      const ys = _.uniq(data.map((item) => item.y)).reverse();
      const vals = data.map((item) => item.val).sort();
      const getColor = toColor(vals.at(0), vals.at(-1), colorBar);
      const rects = [];
      const gridMap = new Map<string, fabric.Rect>();
      for (let i = 0; i < xs.length; i++) {
        for (let j = 0; j < ys.length; j++) {
          const rect = drawRect({
            left: i * cellWidth,
            top: j * cellHeight,
            fill: cell?.fill ?? "#fff",
          });
          rects.push(rect);
          gridMap.set(`${xs[i]}-${ys[j]}`, rect);

          // fabric Object 通过 saveState 额外自定义插入属性字段
          rect.saveState({stateProperties: ['extra-state']})
          Reflect.set(rect, 'extra-state', {
            x: xs[i],
            y: ys[j],
            val: undefined,
          })

          // * 添加事件监听
          // - canvas.on("object:xxx")
          // - object.on("xxx")
          // * 添加动画
          // 监听 onChange 变化调用 canvas.renderAll() 重新渲染 canvas
          // 切记要绑定 this 指向 canvas
          // const initScaleX = rect.scaleX ?? 1;
          // const initScaleY = rect.scaleY ?? 1;
          // rect.on("mouseover", (e) => {
          //   e.target?.animate("scaleX", initScaleX + 0.1, {
          //     onChange: canvas?.renderAll.bind(canvas),
          //     duration: 200,
          //   });
          //   e.target?.animate("scaleY", initScaleY + 0.1, {
          //     onChange: canvas?.renderAll.bind(canvas),
          //     duration: 200,
          //   });
          // });
          // rect.on("mouseout", (e) => {
          //   e.target?.animate("scaleX", initScaleX, {
          //     onChange: canvas?.renderAll.bind(canvas),
          //     duration: 200,
          //   });
          //   e.target?.animate("scaleY", initScaleY, {
          //     onChange: canvas?.renderAll.bind(canvas),
          //     duration: 200,
          //   });
          // });

          // 被 group 包裹的对象, 会被当做一个 group 对象整体
          // 因此内部子对象无法添加事件监听,需要将这些子对象都添加到 canvas 上
          canvas.add(rect);
        }
      }

      // 绘制 xLabel & yLabel
      const texts = [];
      for (let i = 0; i < ys.length; i++) {
        const text = drawText(ys[i], {
          left: -cellWidth - (label?.offsetX ?? 0),
          top: cellHeight * i,
        });
        text.set("originX", "right");
        text.set("angle", label?.rotateYAxis ?? 0);
        texts.push(text);
      }
      for (let i = 0; i < xs.length; i++) {
        const text = drawText(xs[i], {
          left: cellWidth * i,
          top: cellHeight * ys.length + (label?.offsetY ?? 0),
        });
        text.set("angle", label?.rotateXAxis ?? 0);
        texts.push(text);
      }

      for (const o of data) {
        const rect = gridMap.get(`${o.x}-${o.y}`);
        rect?.set("fill", getColor(o.val));
        rect && Reflect.set(rect, 'extra-state', {
          ...Reflect.get(rect, 'extra-state'),
          val: o.val,
        })
      }

      const arrows = [],
        marks = [];
      if (links.length) {
        for (const link of links) {
          const source = gridMap.get(`${link.source.x}-${link.source.y}`)!;
          const target = gridMap.get(`${link.target.x}-${link.target.y}`)!;
          const sourceMark = drawCircle(source.left!, source.top!);
          const targetMark = drawCircle(target.left!, target.top!);
          marks.push(sourceMark);
          marks.push(targetMark);
        }

        for (const link of links) {
          const source = gridMap.get(`${link.source.x}-${link.source.y}`)!;
          const target = gridMap.get(`${link.target.x}-${link.target.y}`)!;
          const arrow = drawArrow(
            [source.left!, source.top!],
            [target.left!, target.top!]
          );
          arrows.push(arrow);
        }
      }

      const heatmapGroup = new fabric.Group(
        [...rects, ...marks, ...arrows, ...texts],
        {
          ...commonOption,
          originX: "center",
          originY: "center",
          left: canvas.getWidth() / 2,
          top: canvas.getHeight() / 2,
          // 屏蔽掉 group 对象对 event 事件的触发和捕获,将 event 事件透传到内部
          evented: false,
        }
      );

      canvas.add(heatmapGroup);

      // 监听画布实现全局事件代理
      let prevTarget: fabric.Object | null = null;
      canvas.on("mouse:move", (e) => {
        if (e.target?.type === "rect") {
          prevTarget = e.target;
          e.target?.animate("scaleX", toX(cell?.scale, 1) + 0.1, {
            onChange: canvas?.renderAll.bind(canvas),
            duration: 200,
          });
          e.target?.animate("scaleY", toY(cell?.scale, 1) + 0.1, {
            onChange: canvas?.renderAll.bind(canvas),
            duration: 200,
          });
        } else {
          if (prevTarget?.type === "rect") {
            prevTarget?.animate("scaleX", toX(cell?.scale, 1), {
              onChange: canvas?.renderAll.bind(canvas),
              duration: 200,
            });
            prevTarget?.animate("scaleY", toY(cell?.scale, 1), {
              onChange: canvas?.renderAll.bind(canvas),
              duration: 200,
            });
            prevTarget = null;
          }
        }
      });
    }

    return () => {
      canvas && canvas.clear();
    };
  }, [data]);

  return <canvas ref={canvasRef} width={width} height={height}></canvas>;
}

🌈 index.d.ts

type ArrayProperty = number | [number, number];

export type Cell = {
  size?: ArrayProperty;
  r?: ArrayProperty;
  scale?: ArrayProperty;
  opacity?: number;
  stroke?: string;
  strokeWidth?: number;
  strokeDashArray?: number[];
  fill?: string;
};

export type Arrow = {
  width?: number;
  height?: number;
  stroke?: string;
  lineWidth?: number;
};

export type Node = {
  fill?: string;
  stroke?: string;
  strokeWidth?: number;
  opacity?: number;
}

export interface Data extends Record<string, any> {
  x: string;
  y: string;
  val: number;
}

export interface Link {
  source: { x: string; y: string };
  target: { x: string; y: string };
}

export type Label = {
  fontSize?: number;
  offsetX?: number; // +:向外偏移;-:向内偏移
  offsetY?: number;
  rotateXAxis?: number;
  rotateYAxis?: number;
}

export interface RelationHeatmapProps {
  data?: Data[];
  links?: Link[];
  width?: number;
  height?: number;
  cell?: Cell;
  arrow?: Arrow;
  label?: Label;
  node?: Node;
  colorBar?: string[];
}

使用

import _ from "lodash";
import React from "react";
import RelationHeatmap from ".";

export default function Test() {
  const data = [...new Array(5).keys()].map((item) => ({
    x: item.toString(),
    y: item.toString(),
    val: _.random(1, 10, false),
  }));
  const links = [
    { source: { x: "0", y: "0" }, target: { x: "1", y: "3" } },
    { source: { x: "1", y: "3" }, target: { x: "2", y: "2" } },
    { source: { x: "2", y: "2" }, target: { x: "3", y: "0" } },
  ];
  return (
    <RelationHeatmap
      data={data}
      links={links}
      cell={{ r: 5 }}
      width={300}
      height={300}
      label={{
        offsetX: 5,
      }}
    />
  );
}