在流程图中实现 Camunda 企业付费版的热力图展示

本文最后更新于:2022年7月8日 下午

前言:这个需求在做完后感觉还是挺有意思的,这里小记一下,方便日后回顾。

初遇需求

对面的产品只给我甩了一张图,然后问我能不能做出这种效果。

image

我一看这不就是 Camunda 的企业付费版功能,然后就去尝试找一下他们的实现。首先去注册了个Camunda账号,但是他们并没有开放这个功能的体验,而且在研究了一下他们的资料后,我猜测他们这个渲染可能是后端直接将热力图的数据加到bpmn图中,前端只是进行渲染。如果现在按照他们这种方式搞,后端工作量可能就不小,影响到其他需求的排期。

既然他们的作法是热力图和流程图在一起渲染的,那我们能不能够让热力图是单独进行渲染呢?答案当然是可以的,heatmap.js 便能够基本满足我们的需求。

实现

后端先给每个关键点对应热力数值,我们将bpmn图进行渲染,渲染完成后获取每个关键点的x,y坐标,通过对偏移量的计算,在对应的关键点位置将热力画出来。

流程图渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const bpmnViewer = (bpmnViewerRef.current = new BpmnViewer({
container: "#canvas"
}));

bpmnViewer
.importXML(diagramXML)
.then(() => {
bpmnViewer.get("canvas").zoom("fit-viewport");
})
.then(() => {
heatmapRender(heatmapData);
})
.catch((err: any) => {
console.log("importXML err: ", err);
});

heatmap 基本渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const heatmapRender = useCallback((data) => {
const bpmnViewer = bpmnViewerRef.current;

let points = [];
const elementRegistry = bpmnViewer.get("elementRegistry");
const viewBox = bpmnViewer.get("canvas").viewbox();
console.log(viewBox);
const {
inner: { x: oX, y: oY },
x: X,
y: Y,
scale
} = viewBox;

const config = {
container: bpmnCanvasRef.current,
width: Math.floor((maxX - minX + 62) * scale),
height: Math.floor((maxY - minY + 62) * scale),
radius: 40 * scale,
maxOpacity: 0.8,
minOpacity: 0,
blur: 0.75,
onExtremaChange: (data) => {
setLegendExtrema(data);
updateLegend(data);
},
};

// 省略坐标计算...

heatmapData = {
// 取流程图的最大宽度,否则渲染会不完整
max: maxV,
min: 0,
data: points,
};
heatmapInstance = heatmap.create(config);
heatmapInstance.setData(heatmapData);

}, []);

这样我们可以得到一个这样的图

30 12 45 56

但是因为我们画出来的是一个圆点,无法覆盖到一个矩形,那如果用更多的圆点呢?

实践下来,用一行3个,3行一共9个圆点就可以实现完整的覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
shapePoints.push(
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 1) / 4)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 1) / 4)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 1) / 2)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 1) / 4)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 3) / 4)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 1) / 4)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 1) / 4)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 1) / 2)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 1) / 2)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 1) / 2)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 3) / 4)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 1) / 2)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 1) / 4)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 3) / 4)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 1) / 2)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 3) / 4)),
value: runCount,
},
{
x: Math.round(Math.abs(shapeX) + Math.floor((shapeW * 3) / 4)),
y: Math.round(Math.abs(shapeY) + Math.floor((shapeH * 3) / 4)),
value: runCount,
}
);

效果如下:

iShot2022-05-30 12 51 48

静态展示完成,接下来是解决拖动下的展示

一开始,我想的是通过bpmn的事件监听来实现,可惜它并没有提供拖动相关的事件。

那我就通过监听鼠标相关事件来实现,虽然在渲染图内部拖动没问题,可鼠标拖动着离开了渲染图或者浏览器外面,就会失去鼠标的坐标,导致下面这种情况的出现

iShot2022-05-30 12 27 33

又研究了一会,我发现bpmn的拖动是通过修改svg的matrix来实现的

iShot2022-05-30 13 04 17

那我就就来监听这个属性的改动来修改热力图的位置,这样即是鼠标移动到了浏览器外面也可以支持了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

const container = document.querySelector('g.viewport')!;

const observerOptions = {
childList: false,
attributes: true,
// Omit (or set to false) to observe only changes to the parent node
subtree: false,
};

handleMouseDown();
const observer = (observerRef.current = new MutationObserver(
(mutationList, observer) => {
mutationList.forEach((item) => {
console.log('item: ', item);
if (item.attributeName === 'transform') {
if (!mousePointRef.current) {
return;
}
const { x, y } = getTranslateXY(item.target);
const offsetX =
x - mousePointRef.current.x + (heatmapOffsetRef.current?.x || 0);
const offsetY =
y - mousePointRef.current.y + (heatmapOffsetRef.current?.y || 0);

attr(
query('.heatmap-canvas')!,
'style',
`
position: absolute;
left: ${offsetX}px;
top: ${offsetY}px
`
);
}
});
}
));
observer.observe(container, observerOptions);

最后的效果:

iShot2022-05-30 12 36 38

小结

这个热力图渲染有意思的地方是对热力关键点位置的计算,涉及了一些初中知识,让我一下子回味无穷。

另外,其实大部分需求可以按照这样的一个过程去完成:

image