04 如何实现复杂交互,了解 SVG 与 Canvas 技术路线差异

508 阅读9分钟

引言

通过前面两篇的学习,我们了解了 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>

 

实际效果演示如下:

2024-11-05 15.26.23.gif

通过上面的内容,我们了解了如何在 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 实现拖拽

实现拖拽功能

具体步骤:

  1. 添加事件监听: 为每个节点(SVG 元素)绑定 mousedown、mousemove 和 mouseup 事件
  2. 记录拖拽状态: 当用户按下鼠标时,记录初始位置,并跟踪鼠标移动
  3. 更新元素位置: 当鼠标移动时,根据鼠标的偏移量更新 SVG 元素的位置
  4. 释放鼠标时结束拖拽: 在 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>

效果如下:

2024-11-07 10.37.40.gif

总结

通过上面两种方案的对比,我们了解到了分别使用两种技术路线实现流程图交互的思路,在基础打好之后,我们接下来就可以进入到本次课程的核心,带领大家实现一套简单但功能完善的流程图库了。