区域绘制技术实战:从原理到项目落地的完整指南

Newb.圆圆 交互 阅读 1,165
赞 5 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月做了一个数据可视化后台,客户要求能“在地图上圈出任意区域,然后筛选出该区域内的设备”。听起来简单,但实际一上手才发现坑不少。一开始我甚至想用现成的库,比如 Leaflet 或者 Mapbox,但后来发现——根本不需要地图底图,客户只是要一个空白画布,用户自己画多边形就行。于是决定:用原生 Canvas + 鼠标/触控事件,自己撸。

区域绘制技术实战:从原理到项目落地的完整指南

为啥不用 SVG?因为后期可能要支持上千个点,Canvas 性能更稳。而且我们只需要绘制和交互,不需要 DOM 结构,Canvas 更轻量。事实证明这个选择是对的,虽然中间折腾得够呛。

核心代码就这几行

先说基础逻辑:用户按下鼠标(或 touchstart),开始记录点;移动时实时画线预览;松开时闭合路径,生成最终区域。关键在于如何高效地记录坐标、绘制路径,并且支持撤销、重做。

下面是最小可运行版本的核心代码(省略了样式和边界处理):

class AreaDrawer {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.points = [];
    this.isDrawing = false;

    this.canvas.addEventListener('mousedown', this.handleStart.bind(this));
    this.canvas.addEventListener('mousemove', this.handleMove.bind(this));
    this.canvas.addEventListener('mouseup', this.handleEnd.bind(this));
  }

  handleStart(e) {
    const { x, y } = this.getRelativePos(e);
    this.points = [{ x, y }];
    this.isDrawing = true;
    this.redraw();
  }

  handleMove(e) {
    if (!this.isDrawing) return;
    const { x, y } = this.getRelativePos(e);
    // 实时预览:只保留最后一个点用于拖动
    const tempPoints = [...this.points, { x, y }];
    this.redraw(tempPoints);
  }

  handleEnd() {
    if (this.points.length < 3) {
      this.points = [];
      this.isDrawing = false;
      this.redraw();
      return;
    }
    this.isDrawing = false;
    // 闭合路径
    this.redraw(this.points, true);
  }

  redraw(points = this.points, isClosed = false) {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    if (points.length === 0) return;

    this.ctx.beginPath();
    this.ctx.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      this.ctx.lineTo(points[i].x, points[i].y);
    }
    if (isClosed) {
      this.ctx.closePath();
      this.ctx.fillStyle = 'rgba(0, 123, 255, 0.2)';
      this.ctx.fill();
    }
    this.ctx.strokeStyle = '#007bff';
    this.ctx.lineWidth = 2;
    this.ctx.stroke();
  }

  getRelativePos(e) {
    const rect = this.canvas.getBoundingClientRect();
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    };
  }
}

这段代码跑起来基本能用,但离生产环境还差得远。真正的问题,全在细节里。

最大的坑:性能问题

测试阶段一切正常,直到 QA 同事在一台老款 MacBook 上试了下——卡成 PPT。仔细一看,原来每次 mousemove 都会触发 redraw,而 redraw 里又调用了 clearRect + 整个路径重绘。高频事件下,这简直是性能杀手。

我一开始想加防抖,但防抖会导致绘制不流畅,用户会觉得“鼠标跟不上手”。后来改成节流(throttle),设了 16ms(≈60fps),效果好多了,但仍有轻微卡顿。

最后灵机一动:为什么非要把所有绘制都放在 Canvas 上?其实可以分两层:底层 Canvas 保存最终区域,上层 Canvas 只负责临时预览线。这样每次 mousemove 只需要清空上层并重绘一条线,性能瞬间提升。

改完后结构变成这样:

<div style="position: relative;">
  <canvas id="final-canvas" style="position: absolute; top: 0; left: 0;"></canvas>
  <canvas id="temp-canvas" style="position: absolute; top: 0; left: 0;"></canvas>
</div>

JS 里也拆成两个 ctx,临时绘制只操作 tempCtx,确认完成后再把路径画到 finalCtx 并清空临时层。亲测有效,连 iPad 上都流畅了。

又踩坑了,touchmove滚动失效

移动端适配时,发现手指一滑页面就跟着滚,根本画不了。查了下才知道,移动端的 touchmove 默认会触发页面滚动,必须手动阻止。

加了这行才搞定:

this.canvas.addEventListener('touchmove', (e) => {
  e.preventDefault();
}, { passive: false });

注意 passive: false,不然某些浏览器会忽略 preventDefault()。这个坑我踩过好几次,每次都要重新翻文档。

边界情况:用户乱点怎么办?

真实用户可不会按你设想的流程走。有人双击、有人快速点击十几次、还有人画完又点一下空白处——结果整个区域消失了。

我们加了几条规则:

  • 点数少于 3 个时不闭合(三角形是最小多边形)
  • 连续两次点击距离太近(<5px)视为重复点击,忽略
  • 点击已绘制区域时,不新建区域,而是进入“编辑模式”(这个功能后来砍了,太复杂)

最头疼的是撤销功能。最初用数组 push/pop,但用户中途改主意,撤到一半又想重画,状态管理一团糟。最后干脆用命令模式,每一步操作存为一个对象,支持 undo/redo。虽然代码量多了,但逻辑清晰多了。

最终的解决方案

综合下来,我们的最终方案是:

  • 双 Canvas 分层:底层存最终结果,上层做临时预览
  • 事件统一处理:mouse 和 touch 事件分别绑定,但内部调用同一套逻辑函数
  • 点过滤:去重、去噪、最小点数校验
  • 导出为 GeoJSON 格式,方便后端处理

导出部分很简单,就是把 points 数组转成标准格式:

exportToGeoJSON() {
  if (this.points.length < 3) return null;
  const coords = this.points.map(p => [p.x, p.y]);
  coords.push(coords[0]); // 闭合
  return {
    type: 'Polygon',
    coordinates: [coords]
  };
}

后端直接拿这个去查数据库里的设备点是否在多边形内,用的是 PostGIS 的 ST_Contains,很稳。

回顾与反思

整体效果还不错,客户验收一次通过。性能、兼容性、交互都达到了预期。但有几个小问题一直没动:

  • 用户画完后,不能微调顶点(拖拽修改)。本来计划做,但工期紧,先砍了
  • 在高 DPI 屏幕上,Canvas 会模糊。加了 devicePixelRatio 适配,但部分安卓机还是有点糊

说实话,第二个问题影响不大,肉眼看不出区别,所以就没深究。有时候“够用就行”比“完美”更重要,尤其是在 deadline 压着的时候。

另外,如果重来一次,我会考虑用 OffscreenCanvas 做更彻底的性能隔离,不过目前方案已经扛住了 200+ 并发用户的压力测试,没必要过度优化。

以上是我个人对区域绘制的完整实战总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多——比如结合 Web Worker 做复杂区域判断,或者用 WebGL 加速大规模绘制——后续会继续分享这类博客。希望这篇踩坑记录对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论