尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

两个重叠圆的计算与绘制:从交点坐标到面积与碰撞检测

两个重叠圆的计算与绘制:从交点坐标到面积与碰撞检测 接到一个很普通的需求在画布上画两个重叠的圆并且让重叠区域单独高亮。一开始觉得这不算事无非是两个圆形再加一层半透明填充。真正动手才发现问题不是“怎么画圆”而是“重叠的部分到底怎么定义、怎么计算、怎么在程序里稳定表达”。两个重叠的圆放到数学、渲染、数据可视化、碰撞检测四个场景里分别对应完全不同的任务。它看起来基础却是判断一个人有没有把“视觉可见”转换成“程序可计算”的试金石。很多人能一两分钟画出视觉效果但一碰到交点坐标、面积计算、边界情况就开始翻车。这篇文章不准备只给一个画圆代码而是想从几何计算、绘制方案、数据语义、工程落地四个角度把“两个重叠的圆”这个最小模型讲透。1. 两个重叠的圆为什么不能只看视觉效果1.1 一个基础图形的四种身份先想一个问题你遇到“两个重叠的圆”时到底要解决什么在 UI 开发里它可能是两个半透明卡片叠在一起要做一个视觉层次在几何编程里它可能是一道需要求交点、算重叠面积的计算题在数据可视化里它通常是两个集合的 Venn 图交集区域表达共同元素在游戏或物理场景里它又可能只是两个圆形物体的碰撞范围判断。同样两个圆不同语境下要输出的东西完全不一样。视觉装饰可能只需要 CSS 两个半透明圆。几何计算需要圆心距离、交点坐标、面积。集合关系需要保证圆心距、半径能表达出“哪些元素属于哪个集合”。碰撞检测只需要一个判断条件距离是否小于半径和。很多需求一开始都像第一种做着做着就会滑向第二、第三甚至第四种。最怕的是你只用视觉方式做了一个“看起来对”的效果等产品经理下一个问题“那这两个圆重叠的面积是多少”出现时才发现没有任何数据支撑。所以在代码层面我建议不要先在视觉层动手而是先把“两个圆到底是什么关系”这个基础函数写清楚。这个基础函数是一切后续操作的起点。1.2 肉眼能看懂的程序不一定能计算人眼看到的是两个圆叠在一起程序看到的是两个圆心坐标、两个半径以及一堆需要分支判断的边界条件。圆与圆的关系不是简单一句“有没有交叉”就能概括的。两个圆可能出现相离、外切、相交、内切、内含五种情况。工程上如果只用“距离小于半径和”去判断那么在相切、内切这些小概率边界上很容易得到错误结果。举个例子两个圆刚好外切距离等于半径之和。如果你写的是d r1 r2那它就是“不重叠”如果写成d r1 r2它就是“重叠”。这个差别在图形渲染里可能不明显但在碰撞检测和面积计算里就是一个空交集和一个宽度为 0 的交集后续算法会出现完全不同的行为。更麻烦的是计算中的除法和根号。交点坐标公式里有a (r1^2 - r2^2 d^2) / (2d)如果d为 0会直接除零面积公式里有根号浮点误差稍微大一点内部也可能变成负数。这些都不是视觉上能看出来的问题而是必须从数学模型上提前规避的问题。所以我的第一个建议是遇到两个圆先写一个classifyCircleRelation函数把关系类型、距离、边界条件全部返回。后面所有功能都建立在这个基础上而不是每次都在业务代码里重新算一遍。2. 数学视角先判断关系再决定算法2.1 两圆之间只有五种关系先约定一下符号。两个圆心分别是(x1, y1)和(x2, y2)半径分别为r1和r2圆心距为d。那么两个圆的关系可以用一个非常简单的表来判断。关系判断条件交点个数实际意义相离d r1 r20没有一点重叠外切d r1 r21刚好接触相交abs(r1 - r2) d r1 r22正常重叠内切d abs(r1 - r2)1小圆在大圆内部相切内含d abs(r1 - r2)0小圆完全在大圆内部同心且半径不同d 0且r1 ! r20小圆被大圆完全覆盖判断顺序也很重要。我一般先判断d 0再判断相离、相切、相交最后判断内含。因为内含和同心都需要用到abs(r1 - r2)而相交的范围正好在“外切”和“内切”之间顺序错了容易出现重复分支。一个容易被忽略的点多数浮点计算中d r1 r2这种完全相等判断并不安全。实际工程里通常会用一个小阈值比如Math.abs(d - (r1 r2)) 1e-6把它当作相切处理。这样能避免浮点误差带来的抖动。2.2 交点坐标用三角形而不是联立方程计算两个圆的交点最直接的想法是解二次方程组。但工程里这么做代码会变得又长又容易出错。更常见的做法是把它看成一个三角形问题。已知三角形三条边r1、r2、d。从圆心c1向两圆交点连线方向投影可以计算投影点位置a再算垂直偏移h。这是一个很经典的推导过程。在 JavaScript 里可以写这样一个函数function getCircleIntersectionPoints(c1, r1, c2, r2) { const dx c2.x - c1.x; const dy c2.y - c1.y; const d Math.hypot(dx, dy); // 没有交点或完全包含时直接返回空数组 if (d r1 r2 || d Math.abs(r1 - r2) || d 0) { return []; } // 投影点到 c1 的距离 const a (r1 * r1 - r2 * r2 d * d) / (2 * d); // 投影点到交点的高度 const h Math.sqrt(Math.max(0, r1 * r1 - a * a)); // 投影点坐标 const px c1.x (a * dx) / d; const py c1.y (a * dy) / d; // 垂直方向的单位偏移 const offsetX (h * dy) / d; const offsetY (h * dx) / d; // 如果 h 近似为 0说明是相切状态只返回一个点 if (h 1e-9) { return [{ x: px, y: py }]; } return [ { x: px offsetX, y: py - offsetY }, { x: px - offsetX, y: py offsetY } ]; }这里用Math.hypot计算距离比手写Math.sqrt(dx * dx dy * dy)更安全也能避免数值溢出。用Math.max(0, r1 * r1 - a * a)做一下保护主要是防止浮点误差让根号内部变成负数。这个函数返回的是一个点数组。相离、内含时返回空数组相切时返回一个点相交时返回两个点。这种设计更符合上层调用习惯拿到数组之后用length判断状态而不是在渲染层再去猜。2.3 重叠面积两个弓形相加如果只是画出视觉效果可能不需要面积。但很多业务场景里“重叠区域有多大”才是真正的核心指标。两圆相交形成的重叠区域可以看作两个弓形相加。弓形面积等于扇形面积减去等腰三角形面积分别对两个圆算一次然后相加。扇形面积公式是0.5 * r^2 * θ三角形面积是0.5 * r^2 * sin θ弓形面积就是二者之差。把两个圆的弓形加起来就是整个重叠区域面积。代码实现如下function getOverlapArea(d, r1, r2) { // 相离或外切没有面积 if (d r1 r2) return 0; // 内切或内含重叠区域等于小圆面积 if (d Math.abs(r1 - r2)) { const smallerRadius Math.min(r1, r2); return Math.PI * smallerRadius * smallerRadius; } // 两圆相交两个弓形面积相加 const part1 r1 * r1 * Math.acos((d * d r1 * r1 - r2 * r2) / (2 * d * r1)); const part2 r2 * r2 * Math.acos((d * d r2 * r2 - r1 * r1) / (2 * d * r2)); const part3 0.5 * Math.sqrt( (-d r1 r2) * (d r1 - r2) * (d - r1 r2) * (d r1 r2) ); return part1 part2 - part3; }这个公式在常规相交情况下是准确的。实际使用中要注意两点d非常接近r1 r2时part3的根号内部可能因为浮点误差变成负数所以在调用前最好把d夹紧到合法范围内。输入参数必须是正数圆心距不能为 0。否则除零会在2 * d * r1这一步提前暴露。如果你想把这个函数放生产环境建议再加一层防御性检查。比如半径小于等于 0 时直接返回 0d为NaN或无穷大时也提前结束。这些边界看起来多余但恰恰是长期维护中最容易出 bug 的地方。3. 绘图与渲染Canvas、SVG、CSS 三种路径3.1 Canvas可控但需要自己管理合成模式在 Canvas 里画两个圆很容易const ctx canvas.getContext(2d); ctx.beginPath(); ctx.arc(100, 100, 60, 0, Math.PI * 2); ctx.fillStyle rgba(255, 0, 0, 0.5); ctx.fill(); ctx.beginPath(); ctx.arc(140, 100, 60, 0, Math.PI * 2); ctx.fillStyle rgba(0, 0, 255, 0.5); ctx.fill();两个半透明圆重叠后视觉上确实会出现一块“混合颜色”区域。但如果你想要的是“只高亮重叠区域而且颜色精确可控”半透明混合就不够用了。这时候有两个思路。第一个是使用globalCompositeOperation multiply让两个图形用正片叠底的方式混合。优点是代码简单缺点是颜色会被混合逻辑影响而且不同浏览器对混合模式的支持可能有细微差异。第二个是先计算两个圆的交点再用Path2D构造重叠区域路径单独填充。这个思路更麻烦但结果完全可预测。我一般推荐这种做法因为它的输入和输出都来自几何计算渲染层只是把数学结果画出来不容易出现“看起来对但逻辑错”的情况。构造路径时需要把两段圆弧拼在一起第一段从交点 A 沿圆 1 到交点 B第二段从交点 B 沿圆 2 回到交点 A。这个过程需要用到圆弧的方向和起始角度判断比直接填两个圆要复杂一些。3.2 SVG数据结构就是图形结构SVG 里画两个圆最直接的方式是两个circle元素circle cx100 cy100 r60 fillrgba(255,0,0,0.5) / circle cx140 cy100 r60 fillrgba(0,0,255,0.5) /视觉上和 Canvas 半透明效果几乎一样。如果要用 SVG 精确表达重叠区域通常有两条路。第一条是用clipPath做交集裁剪。比如先把一个圆作为裁剪区域再画第二个圆然后对结果重新填充。这种方法适合静态图形一旦参数由用户动态改变裁剪路径的更新会比较麻烦。第二条是根据交点计算直接生成path。你需要把两段圆弧转成 SVG 路径里的A命令。这比 Canvas 路径更直观因为 SVG 的d字符串本身就是一个完整的数据结构。比较推荐的做法是把几何计算层和渲染层分开。几何层返回交点、面积、路径端点等数据SVG 层只负责把这些数据拼成path。这样即使后续切换成 Canvas 或 WebGL几何逻辑也不必重写。3.3 CSS视觉上最省事但不能回答几何问题如果只是做一个静态页面上的装饰效果CSS 其实最省事。两个div设置border-radius: 50%再用mix-blend-mode: multiply就能得到重叠效果。.circle { width: 120px; height: 120px; border-radius: 50%; position: absolute; mix-blend-mode: multiply; }但 CSS 有一个天然限制它只处理视觉样式不产生任何几何数据。你永远无法用 CSS 得到两个圆的交点坐标、重叠面积或者关系类型。如果页面上还需要拖拽、碰撞判断、数据联动最终还是得回到 JavaScript 里去计算。所以我的建议是静态展示、不涉及数据计算CSS 足够。需要动态计算用 Canvas 或 SVG并且核心几何逻辑用 JavaScript。需要精确高亮交集区域优先 SVG 路径或 Canvas 路径不要依赖混合模式。三种方式其实不冲突。一个完整项目里完全可以用 SVG 做静态图形用 Canvas 做动态预览用 CSS 做视觉微调。关键是要知道每种方式的能力边界。4. 从图形到数据Venn 图与集合关系4.1 两个圆两个集合一块交集区两个重叠的圆在数据可视化里最常见的名字叫 Venn 图。它表达的是两个集合之间的关系左圆是集合 A右圆是集合 B中间重叠部分是 A ∩ B左右两个独立区域分别表示只属于 A 或只属于 B 的元素。这种图形非常直观但工程实现里有一个容易忽视的地方视觉位置必须和集合语义一致。不是随便放两个圆再标上 A、B 就算 Venn 图。你需要确保左圆、右圆、交集区三个区域的视觉边界可以区分。每个区域都能接收点击或悬停事件。如果集合大小有数值含义视觉面积和真实数值不能偏差太远。这就回到了前面算交点、算路径的能力。Venn 图不是把圆画出来就完事而是要准确画出“交集区域”的边界才能让鼠标事件落在正确的集合上。4.2 最容易踩的数据映射坑半径不能等于数值我见过很多初学者做 Venn 图时直接把集合大小赋值给半径。假如集合 A 的大小是 100集合 B 的大小是 50他们就把rA设为 100rB设为 50。这样做出来的两个圆视觉面积比不是 2:1而是 4:1。原因很简单圆的面积正比于半径的平方。要让面积比等于数据比半径应该与面积的平方根成正比。也就是说如果 A 是 B 的两倍半径应该是约 1.414:1不是 2:1。一个相对可靠的映射方法是const areaA 100; const areaB 50; const rA Math.sqrt(areaA / Math.PI); const rB Math.sqrt(areaB / Math.PI);先根据面积计算半径再画图。这样至少不会出现明显的数据失真。但即便半径映射正确重叠区域的面积也不会精确等于两个集合交集的真实大小。因为重叠区域面积还取决于圆心距而圆心距通常是根据布局需要定的不是根据交集大小反推出来的。所以要提醒一句如果业务环节对面积准确性要求很高尽量避免用 Venn 图硬扛可以在图上标注具体数字或者使用专业的集合可视化库。4.3 一个通用流程位置、半径、文字三步走结合上面的经验我建议用三个步骤来落地一个双集合 Venn 图。第一步确定圆心距。如果只是示意可以固定一个值比如两个圆心的水平距离等于两个半径之和的一半这样能留出足够的重叠区。如果要精确表达交集圆心距往往需要数值求解没有简单的解析公式。第二步从数据计算半径。先按面积比例算出原始半径再乘一个缩放系数让整个图形适配画布尺寸。记得把缩放系数放在同一个环节保持比例一致。第三步决定交集区的绘制方式。最精确的方式还是用路径填充交集区域然后在上面叠加文字和点击事件。如果不要求精确面积可以用半透明填充配合模式混合视觉上也可以接受。这套流程看似基础但已经能覆盖大部分双集合展示需求。你要做的只是把几何计算和渲染逻辑分开后续即使从两个圆扩展到 n 个圆也能沿用同样的分层思想。5. 工程化演进碰撞检测、批量计算和动态交互5.1 圆与圆的碰撞检测比看起来更经典如果把两个重叠的圆放到游戏物理里它的名字就变成了“圆-圆碰撞检测”。对应关系很简单两个圆产生重叠时说明它们发生了碰撞。判断条件更简单const d Math.hypot(c1.x - c2.x, c1.y - c2.y); const isColliding d r1 r2;这个公式比矩形碰撞、多边形碰撞都简单因为它只需要一个距离判断。实际开发里圆也经常被用作碰撞检测的“近似形状”因为性能好、边缘没有方向依赖、旋转不影响判断结果。但简单不等于没有坑。如果两个圆移动速度很快在某一帧里它们可能已经穿过彼此但这一帧计算时距离仍然大于半径和。这种情况叫“隧穿效应”。要解决它需要做连续碰撞检测把运动变化拆成更小的时间步或者用扫掠圆的方式检测路径上是否存在碰撞。所以如果你只做一个静态判断d r1 r2就够了。如果要做高速运动物体的碰撞就要考虑更多边界条件。这里也是“视觉上过了逻辑上没过”的典型场景。5.2 批量场景O(n²) 是第一个边界两个圆的问题很容易扩展到一万个圆。假如有一个地图场景上面有几百个圆形区域需要判断哪些圆互相重叠。最朴素的做法是两两比较那复杂度就是 O(n²)。当 n 是 100 时比较 4950 次还行当 n 是 10000 时要比较约 5000 万次前端会明显卡顿。工程上通常用空间网格或四叉树来优化。空间网格把平面划分成固定大小的格子每个圆只和相邻格子里的圆比较。四叉树则递归地把平面分割成更细的象限减少无效比较。但对于只有两个圆的场景这些优化都不需要。我见过很多初学者一上来就写四叉树结果不仅没解决眼前的问题还引入了一大堆难调试的递归代码。正确做法是先跑通两个圆的情况写一个清晰可测的函数等数量级真的上来了再按瓶颈点换成空间索引。这种“先正确后优化”的思路我觉得比任何具体算法都重要。5.3 从一次计算到可复用函数前面二、三、四章写的所有内容最后应该收敛成一个可复用的函数。我通常会组织成这样function getCircleOverlapInfo(c1, r1, c2, r2) { const d Math.hypot(c1.x - c2.x, c1.y - c2.y); return { distance: d, relation: classifyCircleRelation(d, r1, r2), points: getCircleIntersectionPoints(c1, r1, c2, r2), overlapArea: getOverlapArea(d, r1, r2) }; }返回一个包含距离、关系、交点、面积的对象这样上层既能画图又能做事件判断还能写日志和测试。用对象而不是散装变量不是风格问题而是可维护性问题。以后如果还要扩展“两个圆是否包含某个点”可以直接往对象里加字段如果还要支持“多个圆批量检测”也能把同样的函数循环调用不会让业务层堆满Math.sqrt。这些工程习惯比“画出两个圆”更接近这个主题的长期价值。6. 落地排查与长期使用建议6.1 一套三个层次的排查顺序如果你现在正在处理两个重叠圆相关的 bug我的建议是先按下面这个顺序排查而不是直接改渲染代码。第一层查数据输入。确认两个圆心坐标的单位是否一致半径是否为正数在浏览器里坐标是相对定位还是绝对定位有没有受到父容器 transform 或缩放影响。很多“圆没重叠但判断重叠”的问题都是因为坐标单位不一致。第二层查几何计算。确认距离计算是否正确是用了Math.hypot还是手写的距离公式有没有因为Math.sqrt溢出导致结果错误。再看关系判断分支是否覆盖了相切、内含和同心这几种边界。第三层查渲染。确认圆心的坐标是圆的中心点而不是左上角确认 Canvas 或 SVG 的坐标系是否被scale、translate改过确认描边宽度是否被错误地算进了半径里。这三层排查顺序能解决绝大多数“看起来不对”的问题。先排除数据问题再排除逻辑问题最后才怀疑渲染层。6.2 几个实际高频问题根据实践下面几个问题频率最高。第一个圆看起来没有重叠但代码判断为相交。原因通常是圆心坐标取到了元素左上角或者半透明填充让边界产生了视觉误差。解决办法是把坐标对齐到中心点并在渲染时先用调试线条画出圆心位置。第二个重叠面积返回NaN。这个几乎都是浮点误差导致根号内为负数。可以在调用面积函数前先对d做一个夹紧如果d非常接近r1 r2就当作相离如果非常接近abs(r1 - r2)就当作内含。这样能避免Math.acos或Math.sqrt接收超出定义域的输入。第三个Canvas 在高 DPI 屏幕上模糊。这个和两个圆本身无关但凡是画布的图形都会遇到。通常在初始化时要根据window.devicePixelRatio缩放画布尺寸并同步设置 CSS 尺寸。第四个SVG 动态生成路径时需要拼接大量M、L、A命令一旦参数顺序错误很难肉眼排查。建议不要手拼字符串而是写一个函数输入圆心、半径、起始角度、结束角度返回单段圆弧命令。这样问题能被局部隔离。6.3 什么时候不该自己手写我也要说一句冷静话不是所有场景都适合自己写两个圆的图形逻辑。如果只是做一个静态 Venn 图用现成可视化库可能更快也更可靠。如果要做复杂的图形布尔运算比如多个圆的并集、交集、差集手写路径计算会非常繁琐建议直接用成熟的几何库。如果是轻量展示和数据标注自己写下这套逻辑反而能让你更好地理解数据和行为后续扩展也更顺畅。判断标准很简单你的项目里这个图形是“一次性装饰”还是“长期维护的功能模块”。前者交给工具后者值得自己写。回到开头那次需求。最终我用不到两百行代码完成了整个功能一个函数判断关系一个函数算交点一个函数算面积最后用 Canvas 的Path2D把重叠区域单独填充。后来需要支持多个圆重叠我只需要循环调用同样的几何函数不需要改任何画图逻辑。两个重叠的圆看起来确实很简单。真正值得记住的不是那两个圆本身而是处理问题的方式先判断边界再选择算法最后才写渲染。这个习惯是从无数个“看起来简单”的图形需求里沉淀下来的。
返回列表