Canvas 拖点改几何:坐标换算、命中测试与重绘取舍

举报
岚枫 发表于 2026/11/03 08:23:14 2026/11/03
【摘要】 画布上拖一个点,看似只是把鼠标坐标写回点集,实际要过三段处理:CSS 像素到画布像素、画布像素到模型坐标,最后在画布像素里做命中测试。任何一段漏了缩放,拖拽就会偏。这篇拆开一个 1000×1000 模型映射到 1200×820 画布的实现,并讨论每次 pointermove 全量重算的代价。

一个能拖点的几何演示页,交互代码通常不到一百行。但这不到一百行里,有三套坐标在来回转换,任何一套用错,表现都是「点跟不上鼠标」或者「点拖到边界就跳」。

这篇按一次拖拽的完整路径拆:鼠标事件怎么变成画布坐标、画布坐标怎么变成模型坐标、怎么判断点中了哪个点、拖动时怎么保证不丢事件,以及为什么每次移动都全量重算。

三套坐标

页面本身很小:一个 canvas、一个点数滑块(默认 24,上限 200)、几个视图页签。W、H 是画布的 width/height 属性,固定为 1200 和 820;state.dragIndex 初值是 -1,表示当前没有点在拖动。

先分清三套坐标:

  • 模型坐标:几何算法只认这一套。这里是一个 0..1000 的正方形,凸包、剖分、Voronoi 都在这个范围里算;
  • 画布坐标:canvas 元素内部的像素坐标,这里是 1200×820;
  • CSS 像素:页面实际显示的尺寸,随窗口宽度变化。

模型到画布的映射只有两个函数:

const PAD = 28;

function toPx(p) {
  return {
    x: PAD + (p.x / 1000) * (W - 2 * PAD),
    y: H - PAD - (p.y / 1000) * (H - 2 * PAD),
  };
}

function toModel(px, py) {
  return {
    x: Math.round(((px - PAD) / (W - 2 * PAD)) * 1000 * 100) / 100,
    y: Math.round(((H - PAD - py) / (H - 2 * PAD)) * 1000 * 100) / 100,
  };
}

两个细节:

  1. y 轴要翻转。模型坐标向上为正,画布坐标向下为正,所以 toPx 里是 H - PAD - ...,toModel 里再翻回来。漏掉这一步,图形会上下颠倒,而且因为点集本身不对称,第一眼不一定看得出来。
  2. 留边距。PAD = 28 让模型边界上的点不会贴着画布边缘,也给外接圆留出空间。代价是模型正方形被铺到一个比它更「扁」的区域里,后面会说到这个副作用。

toModel 最后保留了两位小数。这样做的好处是坐标、面积这些数字不会在面板上抖出一长串浮点尾数;代价是拖动精度被限制在 0.01 个模型单位,在 1000 的尺度下可以忽略。

从鼠标事件到画布坐标

拿到 pointerdown 事件后,不能直接把 clientX/clientY 当成画布坐标。画布在页面上可能被 CSS 缩放过,内部尺寸和显示尺寸不一致:

function canvasPos(ev) {
  const r = cv.getBoundingClientRect();
  return {
    x: (ev.clientX - r.left) * (W / r.width),
    y: (ev.clientY - r.top) * (H / r.height),
  };
}

getBoundingClientRect() 给的是显示尺寸,W / r.width 把 CSS 像素换算回画布内部像素。x 和 y 分别算,不假设两者比例相同——虽然这里 height: auto 保持了宽高比,但分开写不依赖这个前提,将来改成固定高度也不会错。

这里没有乘 devicePixelRatio。画布的后备缓冲是 1200×820,并没有按屏幕密度放大,所以在高分屏上线条会略糊;但换算只在 CSS 像素和画布像素之间进行,公式不受影响。要让绘制更清晰,得把后备缓冲乘上 DPR、再用 CSS 尺寸缩回去,那是另一个改动。

另一个容易忽略的点是 CSS 里的 touch-action: none。不加这一行,触屏上拖动会被浏览器解释成滚动手势,pointermove 收到一半就断了。

命中测试:14 个 CSS 像素

判断「点中了哪个点」,用的是线性扫描加一个距离阈值:

function pickPoint(cx, cy) {
  let best = -1;
  let bestD = 14 * (W / cv.getBoundingClientRect().width);
  for (let i = 0; i < state.points.length; i++) {
    const q = toPx(state.points[i]);
    const d = Math.hypot(q.x - cx, q.y - cy);
    if (d < bestD) { bestD = d; best = i; }
  }
  return best;
}

阈值写成 14 * (W / 显示宽度),意思是「14 个 CSS 像素」,再换算成画布单位。为什么不直接用 14 个画布单位?因为画布会被 CSS 缩放:在窄窗口里,14 个画布单位可能只对应七八个 CSS 像素,点会变得很难命中。

点的绘制半径基础是 4 个画布像素(凸包顶点画成 5.4,Voronoi 站点是 4.2),命中半径比它大得多。这是有意的:视觉上点很小,但可点击区域要按手指和鼠标的精度来定,不能按图形大小来定。

线性扫描在这个规模下完全够用:点数上限 260,一次扫描就是 260 次距离计算,比任何空间索引的维护成本都低。点数上万时才需要考虑网格或四叉树。

拖拽:指针捕获与边界钳制

按下、移动、抬起三个事件的职责很清楚:

cv.addEventListener('pointerdown', ev => {
  const { x, y } = canvasPos(ev);
  const idx = pickPoint(x, y);
  if (idx >= 0) {
    state.dragIndex = idx;
    cv.setPointerCapture(ev.pointerId);
  } else {
    const m = toModel(x, y);
    if (m.x >= 0 && m.x <= 1000 && m.y >= 0 && m.y <= 1000 &&
        state.points.length < 260) {
      state.points.push(m);
      render();
    }
  }
});

cv.addEventListener('pointermove', ev => {
  if (state.dragIndex < 0) return;
  const { x, y } = canvasPos(ev);
  const m = toModel(x, y);
  state.points[state.dragIndex] = {
    x: Math.max(0, Math.min(1000, m.x)),
    y: Math.max(0, Math.min(1000, m.y)),
  };
  render();
});

cv.addEventListener('pointerup', () => { state.dragIndex = -1; });

setPointerCapture 是关键。没有它,鼠标一旦移出画布,pointermove 就不再发给画布,拖拽会「粘」在边缘;有了它,指针被画布捕获,移出去也继续收到事件,直到抬起。

这里还缺一个 pointercancel:触摸被系统手势打断时,浏览器不会补发 pointerup,dragIndex 会一直停在原地;下一次 pointermove 可能让一个已经抬起的手指继续拖着点走。加一行 cv.addEventListener('pointercancel', () => { state.dragIndex = -1; }) 更稳。

按下空白处是加点,但先要过两道检查:模型坐标必须落在 0..1000 内,点数必须小于 260。第一道防止点到边距里加出一个越界点;第二道是硬上限,避免误触把页面拖垮。滑块最多生成 200 点,留 60 个余量给手动添加。

拖动时把坐标钳制在 0..1000。不钳制的话,点可以被拖到模型外面。凸包本身不依赖矩形,但 Voronoi 的裁剪假设输入在矩形内,越界点会让结果看起来「少了一块」,却又不报错。

双击删除也走同一套命中测试:

cv.addEventListener('dblclick', ev => {
  const { x, y } = canvasPos(ev);
  const idx = pickPoint(x, y);
  if (idx >= 0 && state.points.length > 3) {
    state.points.splice(idx, 1);
    render();
  }
});

保留至少 3 个点,是因为少于 3 个点没有三角形可剖分,页面会进入退化状态。这个限制放在删除这一侧,比放在剖分里更直接。

每次 pointermove 都全量重算

render() 的实现很直白:

function render() {
  compute();   // 凸包 + 剖分 + Voronoi 两条路径 + 空圆性检查
  clear();
  if (state.view === 'hull') drawHull();
  else if (state.view === 'delaunay') drawDelaunay();
  else if (state.view === 'voronoi') drawVoronoi();
  updateStats();
  if (state.view === 'check') runChecks();
}

compute() 每次都从头算:凸包、Delaunay、Voronoi 裁剪、Voronoi 对偶、空圆性检查。没有脏标记,没有节流,也没有只重画受影响的三角形——拖动一个点,整套结果都重算。

自检页签是个例外:clear() 之后没有对应的 drawCheck(),画布是空的,结果以列表形式渲染在右侧。也就是说切到自检页时,render() 只负责清空画布和跑判据,不负责画图。

这在 24 个点的默认规模下没感觉。把点数拉到 200,每次 pointermove 都要重跑整套剖分与 Voronoi,拖动开始掉帧。

这不是凭感觉:把 compute() 单独放到 Node 里跑(随机点、固定种子),24 个点约 1.4 ms,200 个点约 14 ms。点数是 8.3 倍,耗时约 10 倍,这个区间里增长接近线性。但不能据此外推:Voronoi 裁剪要为每个站点遍历其他所有站点,规模再上去,二次项会逐渐显出来。60fps 的一帧只有 16.7 ms,200 点时计算已经占掉大部分,浏览器里还要再加绘制。

要优化,有几种不同层次的取舍:

  • 按帧节流:pointermove 只记录最新坐标,用 requestAnimationFrame 合并成每帧一次渲染。改动最小,拖拽仍然跟手,只是省掉同一帧里的多次重算;
  • 拖动时降级:拖动过程中只画点和凸包,抬起后再算剖分与 Voronoi。速度快,但拖动时看到的是不完整的结果;
  • 分离重活:把 Voronoi 对偶、统计面板这些不影响拖动反馈的部分延后到 pointerup,拖动时只更新当前视图;
  • 增量更新:只重算受影响的局部。几何上可行,但实现复杂度远高于这个演示的需要。

这个演示页选的是最简单的策略:几何结果不降级,绘制细节另说。在默认点数下这个选择是对的;如果默认点数变成 200,就该先加按帧节流,而不是直接上增量算法。

三个容易踩的坑

第一,画布不是正方形。 模型是 1000×1000,去掉边距后绘制区是 1144×764,x 和 y 的缩放比不同。直线、包含关系、拓扑这些在非均匀缩放下不变,所以三角网看起来仍然是对的;但圆会变成椭圆。画外接圆时如果只用一个半径(按 x 方向缩放),圆就不会穿过三个顶点——在画布上肉眼就能看出来。要修,要么把绘制区改成正方形,要么 x、y 分别缩放后画椭圆。

第二,舍入会吃掉精度。 toModel 保留了两位小数,拖动时点只能落在 0.01 的网格上。对这个演示无所谓,但如果要做「拖到某个精确位置」的交互,舍入应该只发生在显示层,不要写回模型。

第三,双击删除和拖拽会打架。 双击的第一下也是 pointerdown,会先进入拖拽状态;如果两下之间鼠标移动了一两个像素,点会先被挪动,再被删除。多数情况下看不出来,但对「删除前的位置」有要求时,就得区分「按下后没有移动」的点击和真正的拖拽。

还有一个不那么像坑的行为:双击空白处,第一下会加一个点,第二下命中这个新点,dblclick 再把它删掉。点数 ≥3 时净效果是零;如果初始只有 0–2 个点,加完还没达到删除门槛(points.length > 3),新点会留下。它不影响正常使用,但读代码时会让人愣一下——事件处理之间没有显式的状态机,行为是几个 handler 叠加出来的。

交互怎么验证

这类交互很难写单元测试:坐标换算可以单独测,但「拖起来跟不跟手」只能在浏览器里看。

能自动化的是过程:用真实鼠标事件拖一个点,确认控制台没有报错、截图里图形确实变了、页面上的统计数字随之更新。不能自动化的是「拖得准不准」——那需要把鼠标位置和渲染结果做像素级比对,成本远高于这个页面的价值。

所以这部分最终靠一次人工核对:拖一个已知位置的点,看它的模型坐标是否符合预期;拖到边界,看是否被正确钳制;双击,看点是否被删掉且没有删除多余的点。自动化和人工各管一段,不要指望截图能证明坐标是对的。

一条可以直接用的规则

把坐标换算写成两个纯函数,所有输入输出都必须经过它们;命中半径按 CSS 像素定义,再换算到画布单位;重绘策略先保证结果正确,再根据实际规模决定要不要节流。

拖点交互的 bug,大多不是算法错,而是某一条路径上少乘了一个缩放比、少翻了一次 y、或者少钳制了一次边界。把这些换算收进两个函数,bug 就只剩两个地方可藏。

(完)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。