关系图由节点和连线组成,可以清晰展示实体与实体之间的联系。 最近整理一下以前的项目,发现了几年前写的一个流动关系图组件,挺好看的!教大家实现一下~
1.绘制节点
- 节点数据格式
type NodeConfig = {
//节点ID
id: string;
//x坐标
x: number;
//y坐标
y: number;
//字体大小
fontSize: number;
//边距
padding: number;
//文本内容
text: string;
//文本颜色
fontColor: string;
//节点颜色
lineColor: string;
//线宽
lineWidth: number;
//文本宽度
textWidth?: number;
//上下左右包围框
box?: { left: number; right: number; top: number; bottom: number };
//四个中心点,左中,右中,上中,下中
points?: number[][];
//节点高度
height?: number;
};
- 绘制节点外框
ctx.shadowBlur = 0;
ctx.strokeStyle = t.lineColor;
ctx.lineWidth = t.lineWidth;
const height = t.fontSize + t.padding * 2 + t.lineWidth * 2;
const r = height * 0.5;
//设置文本样式
ctx.font = t.fontSize + 'px Arial';
//文本宽度
const textWidth = ctx.measureText(t.text).width || 0;
//中心y坐标
const cy = t.y + r;
//左半圆心x坐标
const cx = t.x - t.lineWidth;
//右半圆心x坐标
const cx1 = t.x + textWidth;
ctx.beginPath();
//左边半圆
ctx.arc(cx, cy, r, 0.5 * Math.PI, 1.5 * Math.PI);
//上边
ctx.moveTo(cx, t.y);
ctx.lineTo(cx1, t.y);
//右边半圆
ctx.arc(cx1, cy, r, 1.5 * Math.PI, 0.5 * Math.PI);
//下边
ctx.lineTo(cx, cy + r);
//绘制外框
ctx.stroke();
- 绘制节点渐变
//从左到右的渐变
const grd = ctx.createLinearGradient(cx - r, cy, cx1 + r, cy);
const c = getColor(t.lineColor);
grd.addColorStop(0, `rgba(${c.red},${c.green},${c.blue},0.8)`);
grd.addColorStop(1, `rgba(${c.red},${c.green},${c.blue},0)`);
ctx.fillStyle = grd;
ctx.fill();
- 绘制文本
//设置字体颜色
ctx.fillStyle = t.fontColor;
//绘制字体
ctx.fillText(t.text, t.x, cy + t.fontSize * 0.5 - t.lineWidth);
- 缓存节点的一些信息,用于绘制连线
//缓存一些信息
t.textWidth = textWidth;
t.height = height;
//包围框范围
t.box = {
left: t.x - t.lineWidth - r,
right: t.x + textWidth + r + t.lineWidth,
top: t.y - t.lineWidth,
bottom: t.y + height + t.lineWidth
};
//四个中心点位置
t.points = this.getPoint(t);
this.nodeMap[t.id] = t;
2.绘制连线和流动小球
- 连线数据格式
type LineConfig = {
//开始节点ID
startId: string;
//结束节点ID
endId: string;
//连线类型 'bezierCurve'贝塞尔曲线 'line'直线
lineType: 'bezierCurve' | 'line';
//连线颜色
lineColor: string;
//小球发光宽度
blurWidth: number;
//连线宽度
lineWidth: number;
//小球数量
pointNum: number;
//小球大小
pointSize: number;
//移动速度
moveStep: number;
};
- 计算节点的四个中心点
//四个中心点,左中,右中,上中,下中
getPoint(t: NodeConfig) {
const height = t.height!;
const r = height * 0.5;
const textWidth = t.textWidth || 0;
const box = t.box || {
left: t.x - t.lineWidth - r,
right: t.x + textWidth + r + t.lineWidth,
top: t.y - t.lineWidth,
bottom: t.y + height + t.lineWidth
};
return [
[box.left, t.y + r],
[box.right, t.y + r],
[t.x + textWidth * 0.5, box.top],
[t.x + textWidth * 0.5, box.bottom]
];
}
- 用节点的四个中心点计算距离,将最短的两个中心点作为连线的起点和终点
//根据起点和终点节点的中心点,计算最短距离的两点的中心点位置
getStartEnd(start: NodeConfig, end: NodeConfig) {
const s = start.points || this.getPoint(start);
const e = end.points || this.getPoint(end);
let min = Number.MAX_SAFE_INTEGER;
let minS = 0,
minE = 0;
for (let i = 0; i < s.length; i++) {
for (let j = 0; j < e.length; j++) {
const d = this.getDistance(s[i], e[j]);
if (min >= d) {
min = d;
minS = i;
minE = j;
}
}
}
return {
start: { x: s[minS][0], y: s[minS][1] },
end: { x: e[minE][0], y: e[minE][1] }
};
}
- 获取连线信息
const { start, end } = this.getStartEnd(startNode, endNode);
//连线ID
const lineId = point.startId + '-' + point.endId;
//小球运动变量,加上小球均分位置,递增该变量形成运动小球
const move = (this.infoMap[lineId] || 0) + point.moveStep;
//设置连线样式
const c = getColor(point.lineColor);
ctx.beginPath();
ctx.shadowBlur = 0;
//连线线宽
ctx.lineWidth = point.lineWidth;
//连线颜色
ctx.strokeStyle = `rgba(${c.red},${c.green},${c.blue},0.3)`;
绘制直线连线
- 绘制直连线
else if (point.lineType === 'line') {
//移动到起点
ctx.moveTo(start.x, start.y);
//绘制直线
ctx.lineTo(end.x, end.y);
ctx.stroke();
- 绘制沿直线的小球
直线公式:Start和End是两个开始点和结束点,t是沿直线的进度变量,范围是[0,1]
- 公式中Start,End 点分别代入对应点的x坐标或y坐标可计算出沿直线的坐标
//均分连线绘制小球
const unit = 1 / point.pointNum;
//两点x坐标范围
const xSize = end.x - start.x;
//两点y坐标范围
const ySize = end.y - start.y;
//小球发光宽度
ctx.shadowBlur = point.blurWidth;
//小球发光颜色
ctx.shadowColor = point.lineColor;
//小球颜色
ctx.fillStyle = point.lineColor;
for (let i = 0; i <= 1; i = i + unit) {
//循环移动
const s = (i + move) % 1;
//计算直线中小球的坐标
const x = start.x + xSize * s;
const y = start.y + ySize * s;
//绘制小球
ctx.beginPath();
ctx.arc(x, y, point.pointSize, 0, 2 * Math.PI);
ctx.fill();
}
- move记录当前小球运动变量,不断递增,范围
[0,1]
,加上小球均分位置i,(i + move)
改变小球位置,形成流动。(i + move) % 1
通过取模让小球在连线上循环。
绘制贝塞尔曲线连线
- 绘制贝塞尔曲线
//连线颜色
const c = getColor(point.lineColor);
//三次贝塞尔曲线
if (point.lineType === 'bezierCurve') {
const cx = (start.x + end.x) * 0.5;
//控制点1
const p0 = {
x: cx,
y: start.y
};
//控制点2
const p1 = {
x: cx,
y: end.y
};
//移动到起点
ctx.moveTo(start.x, start.y);
//绘制曲线
ctx.bezierCurveTo(cx, start.y, cx, end.y, end.x, end.y);
ctx.stroke();
-
绘制沿贝塞尔曲线运动的小球
- 三次贝塞尔曲线公式:Start和End是两个开始点和结束点,P1和P2是两个控制点,t是沿贝塞尔曲线的进度变量,范围是
[0,1]
- 三次贝塞尔曲线公式:Start和End是两个开始点和结束点,P1和P2是两个控制点,t是沿贝塞尔曲线的进度变量,范围是
- 公式中Start,End,P1,P2点分别代入对应点的x坐标或y坐标可计算出沿贝塞尔曲线的坐标
//均分连线绘制小球
const unit = 1 / point.pointNum;
//小球发光宽度
ctx.shadowBlur = point.blurWidth;
//小球发光颜色
ctx.shadowColor = point.lineColor;
//小球颜色
ctx.fillStyle = point.lineColor;
for (let i = 0; i <= 1; i = i + unit) {
//循环移动
const s = (i + move) % 1;
const a = 1 - s;
//计算三次贝塞尔曲线中小球的坐标
const x =
start.x * Math.pow(a, 3) +
3 * s * Math.pow(a, 2) * p0.x +
3 * Math.pow(s, 2) * a * p1.x +
Math.pow(s, 3) * end.x;
const y =
start.y * Math.pow(a, 3) +
3 * s * Math.pow(a, 2) * p0.y +
3 * Math.pow(s, 2) * a * p1.y +
Math.pow(s, 3) * end.y;
ctx.beginPath();
//绘制小球
ctx.arc(x, y, point.pointSize, 0, 2 * Math.PI);
ctx.fill();
}
3.移动节点
- 添加鼠标动作
this.canvas.addEventListener('pointerdown', this.onMouseDown.bind(this));
this.canvas.addEventListener('pointermove', this.onMouseMove.bind(this));
this.canvas.addEventListener('pointerup', this.onMouseUp.bind(this));
- 鼠标按下动作
//鼠标按下,遍历节点,获取选中节点
onMouseDown(e: PointerEvent) {
const x = e.offsetX;
const y = e.offsetY;
//从后往前遍历节点,后面添加的节点在上面
for (let i = this.nodes.length - 1; i >= 0; i--) {
const box = this.nodes[i].box!;
//鼠标在节点范围内
if (x >= box.left && x <= box.right && y >= box.top && y <= box.bottom) {
//设置选中节点ID
this.targetId = this.nodes[i].id;
//开启移动
this.isMove = true;
//设置鼠标样式
this.canvas.style.cursor = 'move';
break;
}
}
}
- 鼠标移动动作
//鼠标移动,修改节点的坐标
onMouseMove(e: PointerEvent) {
if (this.isMove && this.targetId) {
const node = this.nodeMap[this.targetId];
//将鼠标位置设置成节点位置中心位置
node.x = e.offsetX - node.textWidth! * 0.5;
node.y = e.offsetY - node.height! * 0.5;
}
}
- 鼠标抬起动作
//鼠标抬起
onMouseUp() {
//置空选中节点ID
this.targetId = '';
//关闭移动
this.isMove = false;
//鼠标样式恢复默认
this.canvas.style.cursor = 'default';
}
4.使用流动关系图
const cLine = new CanvasLines({
//画布DOM
el: document.getElementById('myCanvas') as HTMLCanvasElement,
//背景颜色
bg: '#505050',
//画布大小
height: 800,
width: 800,
//节点数据
nodes: [
{
id: 'a',
x: 100,
y: 50,
text: '开始节点A',
fontSize: 14,
padding: 4,
fontColor: 'white',
lineColor: '#b1e2f1',
lineWidth: 2
},
{
id: 'b',
x: 600,
y: 250,
text: '结束节点B',
fontSize: 14,
padding: 4,
fontColor: 'white',
lineColor: '#b1e2f1',
lineWidth: 2
},
{
id: 'c',
x: 500,
y: 400,
text: '开始节点C',
fontSize: 14,
padding: 4,
fontColor: 'white',
lineColor: '#ffd700',
lineWidth: 2
},
{
id: 'd',
x: 200,
y: 650,
text: '结束节点D',
fontSize: 14,
padding: 4,
fontColor: 'white',
lineColor: '#ffd700',
lineWidth: 2
},
{
id: 'e',
x: 500,
y: 650,
text: '结束节点E',
fontSize: 14,
padding: 4,
fontColor: 'white',
lineColor: '#ffd700',
lineWidth: 2
}
],
//连线数据
lines: [
{
startId: 'a',
endId: 'b',
lineType: 'bezierCurve',
lineColor: '#b1e2f1',
blurWidth: 10,
lineWidth: 4,
pointNum: 5,
pointSize: 6,
moveStep: 0.005
},
{
startId: 'c',
endId: 'd',
lineType: 'line',
lineColor: '#ffd700',
blurWidth: 10,
lineWidth: 4,
pointNum: 5,
pointSize: 6,
moveStep: 0.005
},
{
startId: 'c',
endId: 'e',
lineType: 'line',
lineColor: '#ffd700',
blurWidth: 10,
lineWidth: 4,
pointNum: 5,
pointSize: 6,
moveStep: 0.005
}
]
});
//绘制关系图
cLine.draw();
GitHub地址
https://github.com/xiaolidan00/demo-vite-ts