Open jtwang7 opened 1 year ago
基于 canvas 2D (fabric.js 库) 实现的热力图可视化效果。相较于传统热力图实现,在此基础上添加了内部 link 连接。 实现功能:
🌈 index.tsx
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, }} /> ); }
数据可视化 - 🔥 关系热力图 ( Link )
简介
基于 canvas 2D (fabric.js 库) 实现的热力图可视化效果。相较于传统热力图实现,在此基础上添加了内部 link 连接。 实现功能:
可视化效果
代码
🌈
index.tsx
🌈 index.d.ts
使用