引言
通过前面两篇的学习,我们了解了 SVG 和 Canvas 的基础概念,那如果我们要实现一个流程图库,这两种技术的关键差异点有哪些呢?我们将在这一章做具体分析。
在 Canvas 中添加交互功能和 HTML 元素不同,因为 Canvas 是一个基于像素的绘图区域,并不直接支持时间监听(如点击、悬停等)。但是我们可以通过 JavaScript 手动检测用户的操作,比如鼠标点击、移动等,并根据这些操作做出响应。这通常涉及事件监听和命中检测(Hit Detection).
下面我们将详细介绍一下如何在 Canvas 中添加交互功能。
Canvas 中如何添加交互功能
1. 添加事件监听器
尽管 Canvas 本身没有事件监听的内置机制,但我们可以为 Canvas 元素绑定常见的 DOM 事件,如 click、mousemove、mousedown 等。通过这些事件,你可以获取鼠标的坐标,判断是否与图形重叠,从而触发交互功能。
const canvas = document.getElementById('myCanvas');
canvas.addEventListener('click', handleCanvasClick);
canvas.addEventListener('mousemove', handleMouseMove);
说明:
- click:监听鼠标点击事件
- mousemove:监听鼠标移动事件
2. 获取鼠标位置
在处理交互时,第一步是确定用户点击或移动鼠标时的具体位置。可以通过事件对象中的 event.clientX 和 event.clientY 获取鼠标的坐标,但这些坐标是相对于整个浏览器窗口的,我们需要将它们转换为相对于 Canvas 的坐标。
function getMousePosition(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
说明:
- getBoundingClientRect() 方法获取 Canvas 元素在页面中的位置
- event.clientX 和 event.clientY 是鼠标相对于整个浏览器窗口的坐标,减去 Canvas 的偏移量后,可以得到鼠标相对于 Canvas 的位置
3. 实现命中检测(Hit Detection)
在获取了鼠标的坐标后,下一步是判断鼠标是否在特定的图形上,这个过程称为命中检测。 对于不同的形状,命中检测的算法不同。我们以矩形和圆形为例:
3.1 检测鼠标是否在矩形内
矩形的命中检测相对简单,只需判断鼠标的 x、y 坐标是否在矩形的边界内:
function isInsideRect(mouse, rect) {
return mouse.x > rect.x && mouse.x < rect.x + rect.width &&
mouse.y > rect.y && mouse.y < rect.y + rect.height;
}
// 示例矩形
const rect = { x: 100, y: 50, width: 100, height: 50 };
// 鼠标点击事件处理
function handleCanvasClick(event) {
const mousePos = getMousePosition(canvas, event);
if (isInsideRect(mousePos, rect)) {
alert('点击了矩形!');
}
}
说明:
- isInsideRect() 函数检测鼠标位置是否位于矩形边界内
- 如果点击在矩形内,触发相应的交互(如弹出提示框)
3.2 检测鼠标是否在圆形内
圆形的命中检测需要判断鼠标与圆心的距离是否小于半径:
function isInsideCircle(mouse, circle) {
const dx = mouse.x - circle.x;
const dy = mouse.y - circle.y;
return Math.sqrt(dx * dx + dy * dy) < circle.radius;
}
// 示例圆形
const circle = { x: 200, y: 200, radius: 50 };
// 鼠标点击事件处理
function handleCanvasClick(event) {
const mousePos = getMousePosition(canvas, event);
if (isInsideCircle(mousePos, circle)) {
alert('点击了圆形!');
}
}
说明:
- isInsideCircle() 函数通过计算鼠标到圆心的距离是否小于圆的半径来判断是否命中
- 如果鼠标点在圆形内,触发交互
4. 添加鼠标移动效果
接着我们可以通过监听 mousemove 事件实现鼠标悬停效果。例如,当鼠标移动到某个图形上时,可以改变它的样式或显示额外信息。
function handleMouseMove(event) {
const mousePos = getMousePosition(canvas, event);
// 清除之前的绘制
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 判断鼠标是否在矩形内
if (isInsideRect(mousePos, rect)) {
ctx.fillStyle = 'lightblue'; // 悬停时改变颜色
} else {
ctx.fillStyle = 'lightgreen'; // 默认颜色
}
// 重新绘制矩形
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
}
// 绑定鼠标移动事件
canvas.addEventListener('mousemove', handleMouseMove);
说明:
- handleMouseMove() 事件处理函数在鼠标移动时判断鼠标是否悬停在图形上
- 通过 clearRect() 清除画布后重新绘制图形,并根据鼠标位置动态改变其样式
5. 添加拖拽功能
通过 mousedown、mousemove 和 mouseup 事件,我们可以实现图形的拖拽功能
let dragging = false;
let offsetX, offsetY;
canvas.addEventListener('mousedown', (event) => {
const mousePos = getMousePosition(canvas, event);
if (isInsideRect(mousePos, rect)) {
dragging = true;
offsetX = mousePos.x - rect.x;
offsetY = mousePos.y - rect.y;
}
});
canvas.addEventListener('mousemove', (event) => {
if (dragging) {
const mousePos = geMousePosition(canvas, event);
rect.x = mousePos.x - offsetX;
rect.y = mousePos.y - offsetY;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
}
});
canvas.addEventListener('mouseup', () => {
dragging = false;
});
说明:
- mousedown:当鼠标按下时,检测鼠标是否在矩形内,若在则开启拖拽模式,并记录鼠标点击的位置与矩形位置的偏移量
- mousemove:当拖拽模式开启时,根据鼠标的移动更新矩形的坐标
- mouseup:当鼠标松开时,结束拖拽
6. 完整代码示例
<canvas id="myCanvas" width="400" height="300"></canvas>
<script>
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
let rect = { x: 100, y: 50, width: 100, height: 50 };
let dragging = false;
let offsetX, offsetY;
function getMousePosition(canvas, event) {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function isInsideRect(mouse, rect) {
return mouse.x > rect.x && mouse.x < rect.x + rect.width &&
mouse.y > rect.y && mouse.y < rect.y + rect.height;
}
canvas.addEventListener('mousedown', (event) => {
const mousePos = getMousePosition(canvas, event);
if (isInsideRect(mousePos, rect)) {
dragging = true;
offsetX = mousePos.x - rect.x;
offsetY = mousePos.y - rect.y;
}
});
canvas.addEventListener('mousemove', (event) => {
if (dragging) {
const mousePos = getMousePosition(canvas, event);
rect.x = mousePos.x - offsetX;
rect.y = mousePos.y - offsetY;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
}
});
canvas.addEventListener('mouseup', () => {
dragging = false;
});
// 初始绘制
ctx.fillStyle = 'lightgreen';
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
</script>
实际效果演示如下:
通过上面的内容,我们了解了如何在 Canvas 中添加交互功能。简单总结一下:首先,利用事件监听器捕获鼠标的点击、移动等操作,然后通过命中检测判断鼠标是否与特定图形交互。我们还实现了基础的悬停效果、点击事件和图形拖拽功能。通过这些方法,我们可以为 Canvas 应用 添加丰富的交互功能,实现如流程图、游戏、数据可视化等场景中的动态效果。
SVG 中实现交互功能
相对于 Canvas,使用 SVG 实现流程图的拖拽等交互功能则非常方便,因为 SVG 元素是 DOM 的一部分,能够像其他 HTML 元素一样轻松的添加事件监听器。借助 JavaScript,你可以在 SVG 中实现节点的拖拽、点击、鼠标悬停等交互功能。下面我们将逐步讲解如何使用原生 SVG 和 JavaScript 实现流程图的拖拽功能。
创建一个简单的 SVG 流程图
首先,我们定义一个简单的 SVG 流程图,包含几个节点,如矩形和圆形。每个图形代表流程图的一个节点,后续我们将实现拖拽这些节点的功能。
HTML 结构:
<svg id="flowchart" width="500" height="400" style="border: 1px solid black;">
<!-- 节点 1:矩形 -->
<rect id="rect1" x="50" y="50" width="100" height="50" fill="lightblue" stroke="black" stroke-width="2"></rect>
<!-- 节点 2:圆形 -->
<circle id="circle1" cx="200" cy="100" r="30" fill="lightgreen" stroke="black" stroke-width="2"></circle>
<!-- 节点 3:矩形 -->
<rect id="rect2" x="300" y="200" width="100" height="50" fill="lightyellow" stroke="black" stroke-width="2"></rect>
</svg>
在这里,我们绘制了两个矩形和一个圆形节点,下面将通过 JavaScript 实现拖拽
实现拖拽功能
具体步骤:
- 添加事件监听: 为每个节点(SVG 元素)绑定 mousedown、mousemove 和 mouseup 事件
- 记录拖拽状态: 当用户按下鼠标时,记录初始位置,并跟踪鼠标移动
- 更新元素位置: 当鼠标移动时,根据鼠标的偏移量更新 SVG 元素的位置
- 释放鼠标时结束拖拽: 在 mouseup 事件中结束拖拽
我们直接上代码
const svg = document.getElementById('flowchart');
// 记录选中的元素
let selectedElement = null;
let offset = { x: 0, y: 0 };
// 添加时间监听
svg.addEventListener('mousedown', startDrag);
svg.addEventListener('mousemove', drag);
svg.addEventListener('mouseup', endDrag);
function startDrag(event) {
if (event.target.tagName === 'rect' || event.target.tagName === 'circle') {
selectedElement = event.target;
const { x: svgPx, y: svgPy } = getMousePosition(event);
// 计算鼠标点击处相对元素位置的偏移量
if (selectedElement.tagName === 'rect') {
offset.x = svgPx - parseFloat(selectedElement.getAttribute('x'));
offset.y = svgPy - parseFloat(selectedElement.getAttribute('y'));
} else if (selectedElement.tagName === 'circle') {
offset.x = svgPx - parseFloat(selectedElement.getAttribute('cx'));
offset.y = svgPy - parseFloat(selectedElement.getAttribute('cy'));
}
}
}
function drag(event) {
if (selectedElement) {
const { x: svgPx, y: svgPy } = getMousePosition(event);
// 更新元素位置
if (selectedElement.tagName === 'rect') {
selectedElement.setAttribute('x', svgPx - offset.x);
selectedElement.setAttribute('y', svgPy - offset.y);
} else if (selectedElement.tagName === 'circle') {
selectedElement.setAttribute('cx', svgPx - offset.x);
selectedElement.setAttribute('cy', svgPy - offset.y);
}
}
}
function endDrag() {
selectedElement = null; // 移除元素
}
// 获取鼠标在 SVG 画布中的相对位置
function getMousePosition(event) {
const CTM = svg.getScreenCTM(); // 获取 SVG 的坐标转换举证
return {
x: (event.clientX - CTM.e) / CTM.a,
y: (event.clientY - CTM.f) / CTM.d,
};
}
代码解释:
- startDrag(event): 当用户按下鼠标时,我们检查目标是否为 rect 或 circle,并将其设置为 selectedElement。同时计算鼠标点击位置与元素的偏移量 offset
- drag(event) :当用户拖动鼠标时,根据鼠标位置更新 selectedElement 的 x 和 y(矩形) 或 cx 和 cy(圆形)属性,实时改变元素位置
- endDrag() :当鼠标松开时,结束拖拽,将 selectedElement 置为 null
- getMousePosition(event): 将鼠标坐标转换为 SVG 坐标,使用 getScreenCTM() 获取 SVG 的坐标转换矩阵,并进行坐标转换
改进拖拽体验
限制元素移动范围
为了防止元素被拖拽出 SVG 画布边界,可以在 drag 函数中添加边界检测逻辑,确保元素不会超出指定范围。
function drag(event) {
if (selectedElement) {
const { x: svgPx, y: svtPy } = getMousePosition(event);
const svgWidth = svg.width.baseVal.value;
const svgHeight = svg.height.baseVal.value;
let newX, newY, selectedElementWidth, selectedElementHeight;
if (selectedElement.tagName === 'rect') {
selectedElementWidth = selectedElement.width.baseVal.value;
selectedElementHeight = selectedElement.height.baseVal.value;
newX = svgPx - offset.x;
newY = svgPy - offset.y;
// 防止矩形超出边界
newX = Math.max(0, Math.min(newX, svgWidth - selectedElementWidth));
newY = Math.max(0, Math.min(newX, svgHeight - selectedElementHeight));
// 更新元素位置属性
selectedElement.setAttribute('x', newX);
selectedElement.setAttribute('y', newY);
} else if (selectedElement.tagName === 'circle') {
selectedElementWidth = selectedElement.r.baseVal.value;
newX = svgPx - offset.x;
newY = svgPy - offset.y;
// 防止圆形超出边界
newX = Math.max(selectedElementWidth, Math.min(newX, svgWidth - selectedElementWidth));
newY = Math.max(selectedElementWidth, Math.min(newY, svgHeight - selectedElementWidth));
selectedElement.setAttribute('cx', newX);
selectedElement.setAttribute('cy', newY);
}
}
}
视觉反馈
为了让用户有更好的拖拽体验,可以在 mousedown 时为被选中的元素添加边框或阴影,表示当前正在拖拽该元素。通过改变元素的 stroke 属性或添加 class 来实现。
function startDrag(event) {
if (event.target.tagName === 'rect' || event.target.tagName === 'circle') {
selectedElement = event.target;
selectedElement.style.cursor = 'grabbing'; // 改变鼠标样式
selectedElement.setAttribute('stroke', 'red'); // 给选中元素添加红色边框
const { x: svgPx, y: svgPy } = getMousePosition(event);
// 计算鼠标点击处相对元素位置的偏移量
if (selectedElement.tagName === 'rect') {
offset.x = svgPx - parseFloat(selectedElement.getAttribute('x'));
offset.y = svgPy - parseFloat(selectedElement.getAttribute('y'));
} else if (selectedElement.tagName === 'circle') {
offset.x = svgPx - parseFloat(selectedElement.getAttribute('cx'));
offset.y = svgPy - parseFloat(selectedElement.getAttribute('cy'));
}
}
}
完整示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVG Drag and Drop Flowchart</title>
<style>
svg {
border: 1px solid black;
}
rect, circle {
cursor: grab;
}
</style>
</head>
<body>
<svg id="flowchart" width="500" height="400">
<rect id="rect1" x="50" y="50" width="100" height="50" fill="lightblue" stroke="black" stroke-width="2"></rect>
<circle id="circle1" cx="200" cy="100" r="30" fill="lightgreen" stroke="black" stroke-width="2"></circle>
<rect id="rect2" x="300" y="200" width="100" height="50" fill="lightyellow" stroke="black" stroke-width="2"></rect>
</svg>
<script>
const svg = document.getElementById('flowchart');
let selectedElement = null;
let offset = { x: 0, y: 0 };
svg.addEventListener('mousedown', startDrag);
svg.addEventListener('mousemove', drag);
svg.addEventListener('mouseup', endDrag);
function startDrag(event) {
if (event.target.tagName === 'rect' || event.target.tagName === 'circle') {
selectedElement = event.target;
selectedElement.style.cursor = 'grabbing'; // 改变鼠标样式
selectedElement.setAttribute('stroke', 'red'); // 给选中元素添加红色边框
const { x: svgPx, y: svgPy } = getMousePosition(event);
// 计算鼠标点击处相对元素位置的偏移量
if (selectedElement.tagName === 'rect') {
offset.x = svgPx - parseFloat(selectedElement.getAttribute('x'));
offset.y = svgPy - parseFloat(selectedElement.getAttribute('y'));
} else if (selectedElement.tagName === 'circle') {
offset.x = svgPx - parseFloat(selectedElement.getAttribute('cx'));
offset.y = svgPy - parseFloat(selectedElement.getAttribute('cy'));
}
}
}
function drag(event) {
if (selectedElement) {
const { x: svgPx, y: svtPy } = getMousePosition(event);
const svgWidth = svg.width.baseVal.value;
const svgHeight = svg.height.baseVal.value;
let newX, newY, selectedElementWidth, selectedElementHeight;
if (selectedElement.tagName === 'rect') {
selectedElementWidth = selectedElement.width.baseVal.value;
selectedElementHeight = selectedElement.height.baseVal.value;
newX = svgPx - offset.x;
newY = svgPy - offset.y;
// 防止矩形超出边界
newX = Math.max(0, Math.min(newX, svgWidth - selectedElementWidth));
newY = Math.max(0, Math.min(newX, svgHeight - selectedElementHeight));
// 更新元素位置属性
selectedElement.setAttribute('x', newX);
selectedElement.setAttribute('y', newY);
} else if (selectedElement.tagName === 'circle') {
selectedElementWidth = selectedElement.r.baseVal.value;
newX = svgPx - offset.x;
newY = svgPy - offset.y;
// 防止圆形超出边界
newX = Math.max(selectedElementWidth, Math.min(newX, svgWidth - selectedElementWidth));
newY = Math.max(selectedElementWidth, Math.min(newY, svgHeight - selectedElementWidth));
selectedElement.setAttribute('cx', newX);
selectedElement.setAttribute('cy', newY);
}
}
}
function endDrag() {
if (selectedElement) {
selectedElement.style.cursor = 'default';
selectedElement.setAttribute('stroke', 'black');
}
selectedElement = null;
}
function getMousePosition(event) {
const CTM = svg.getScreenCTM();
return {
x: (event.clientX - CTM.e) / CTM.a,
y: (event.clientY - CTM.f) / CTM.d
};
}
</script>
</body>
</html>
效果如下:
总结
通过上面两种方案的对比,我们了解到了分别使用两种技术路线实现流程图交互的思路,在基础打好之后,我们接下来就可以进入到本次课程的核心,带领大家实现一套简单但功能完善的流程图库了。