ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图片热区插件开发全解:坐标换算、命中检测与避坑指南

图片热区插件开发全解:坐标换算、命中检测与避坑指南

简介:一份基于 jQuery 的图片热区交互插件源码,面向前端开发者与交互设计人员,用于在图片上自由绘制矩形、圆形或不规则区域并为每个热区绑定独立链接或事件,适合在线地图、产品展示和交互式教程等场景。压缩包共8个文件,包含JS核心库与依赖库、CSS样式、HTML演示页、PNG背景素材、README说明及工程配置,整体仅209KB,结构紧凑,便于下载后直接预览与部署。已有2271人学习下载,适合初学者快速上手。通过演示页与项目文件,可学习热区绘制、链接绑定、样式定制等关键写法;插件基于jQuery构建,源码注释详细,支持在IntelliJ IDEA等IDE中预览编辑,有助于二次开发与团队协作。

1. 图片热区插件到底是什么:不只是一张图上画几个透明方块

电商场景里最常见的交互不是按钮,而是商品图上那些可点击的局部区域:鞋子、背带、配饰,鼠标悬停过去就弹提示、跳链接或者高亮。很多人第一反应是给<img>套<map><area>,真做起来才知道这套老方案在响应式布局和动态数据面前有多别扭。这个图片热区 js 插件要解决的,就是「把一张普通图片变成可配置的交互地图」这整件事。它适合两类人:一类是电商、营销页开发,需要快速在图片上圈定可点击区域并绑定跳转;另一类是二次开发的学习者,能从一个完整的热区编辑器和运行时渲染方案里,学会坐标系统、命中检测和事件交互这些前端基本功。它的核心思路很简单——把热区数据抽象成一份 JSON,编辑器和渲染层各管一头,图片随意缩放,热区自己跟着走。

2. 设计思路与技术选型:为什么不用现成 map 标签,而是自研热区引擎

2.1 原生 map/area 标签的痛点

要理解这个插件为什么存在,得先看清楚<map>标签的死穴。<area>的coords是写死像素的,图片一旦不是 1:1 原尺寸展示,点击区域就全部偏移。响应式页面里object-fit: cover一上场,原图被裁剪,coords直接作废。更麻烦的是,运营要自己改热区——你不大可能让运营去手改标签属性,必须提供一个可视化编辑界面。

而一个独立插件可以做得更彻底:热区数据从图片解耦出来,存成 JSON,渲染时实时换算坐标。图片怎么缩放、怎么裁剪,只要把换算规则定死,热区永远贴着图走。这个思路也是整个项目的架构基石。

2.2 插件架构总览:编辑器与运行时两层拆分

整个项目拆成两个互相独立的部分:

hotspot-plugin/ ├── src/ │ ├── core/ # 运行时核心,不依赖 DOM 状态 │ │ ├── Hotspot.js # 主控制器 │ │ ├── types.js # 热区类型定义(rect/circle/polygon) │ │ └── hitTest.js # 命中检测算法 │ ├── editor/ # 编辑器模块,负责可视化绘制 │ │ ├── Editor.js # 编辑器主逻辑 │ │ ├── drawTool.js # 绘制工具(矩形/圆/多边形) │ │ └── overlay.js # 编辑器覆盖层渲染 │ ├── renderer/ │ │ ├── overlayLayer.js # 展示层热区渲染 │ │ └── tooltip.js # 悬停提示气泡 │ └── utils/ │ ├── coordinates.js # 坐标换算工具 │ └── dom.js # DOM 操作辅助

编辑器的职责是「把用户手指或鼠标画的图形转成结构化数据」;运行时的职责是「把结构化数据渲染成可交互的热区」。两者之间唯一的约定是数据格式。这个拆分带来的好处是:线上展示根本不需要引入编辑器代码,生产包体积小一半;如果运营需要临时改热区,再单独加载编辑器模块。

2.3 技术选型方案

项目不依赖任何框架,原生 JavaScript 实现。用原生有几层考虑。首先热区交互本身就是 DOM 事件和坐标计算,框架在这里帮不上太多忙,反而徒增依赖体积。其次,这个插件要被不同技术栈的项目引用——有人插进 Vue,有人插进 React,有人直接在 SSR 页面里用,原生模块可以以零依赖的形式发布,任何环境都能直接引入。

渲染层采用的是「绝对定位覆盖层方案」。在图片外层包一个相对定位容器,热区用绝对定位的 DOM 或 SVG 元素铺在图片上方。相比 canvas 方案,DOM 方案的好处是热区天然支持 CSS hover 效果、事件绑定和自定义 tooltip,不需要自己额外做事件拾取。绘制和展示统一用 SVG 而不是 Div 切法,是为了一次性支持矩形、圆和不规则多边形三种形状,SVG 图形属性直接用数据坐标设置,少一层换算。

3. 目录结构与核心模块拆解:一张图上的热区是怎么被算出来的

3.1 坐标系统的确立

所有热区逻辑的第一步,是确定坐标存什么格式。插件选择「百分比 + 像素」双轨制:JSON 数据里存相对于原图的百分比坐标,渲染时换算成当前显示尺寸的像素坐标。原图设计稿宽度是 1600px,一个热区 x 坐标是 800px,那存的就是 50%;页面把这个图渲染成 800px 宽,热区实际渲染 x 就是 400px。

这个设计的原因很简单:百分比坐标天然抗缩放,图片不管长宽比变化多少,热区相对位置不会跑。像素坐标只存在于渲染层和编辑器层,用于实际 DOM 操作和命中检测。

3.2 核心模块一:坐标换算工具

坐标换算是整个插件的最小核心单元,所有模块都依赖它:

// utils/coordinates.js const coordinates = { /** * 将真实像素坐标转换为相对于原始图片尺寸的百分比坐标 */ toPercent(point, originWidth, originHeight) { return { x: (point.x / originWidth) * 100, y: (point.y / originHeight) * 100, }; }, /** * 将百分比坐标转换为当前容器尺寸下的像素坐标 */ toPixel(percentPoint, containerWidth, containerHeight) { return { x: (percentPoint.x / 100) * containerWidth, y: (percentPoint.y / 100) * containerHeight, }; }, /** * 将百分比热区数据转换成渲染层的绝对定位样式 */ hotspotToStyle(hotspot, containerWidth, containerHeight) { const base = { left: `${hotspot.x}%`, top: `${hotspot.y}%` }; if (hotspot.type === 'rect') { return { ...base, width: `${hotspot.width}%`, height: `${hotspot.height}%`, }; } if (hotspot.type === 'circle') { return { ...base, width: `${hotspot.radius * 2}%`, height: `${hotspot.radius * 2}%`, }; } return base; }, };

上面这段代码里的关键点是toPercent和toPixel。运行时渲染阶段,插件拿到原始图片尺寸和容器当前尺寸,用toPixel把热区数据转成像素用于命中检测;DOM 样式阶段却可以直接用百分比设置,因为left/top/width/height天然支持百分比单位,浏览器会替我们做换算。这两个方法多存储 double 计算,但性能完全不是问题——一个页面上的热区通常不会超过 50 个。

使用注意:originWidth/originHeight必须取自图片naturalWidth/naturalHeight,而不是offsetWidth。offsetWidth是图片实际渲染宽度,可能是被 CSS 缩放过的,用它当基准会导致热区在明暗两套渲染尺寸之间来回漂移。这是跑这个项目时最容易踩的第一个坑。

3.3 核心模块二:命中检测算法

热区渲染成 DOM 之后,浏览器自己会处理点击事件,看起来好像不需要命中检测模块。但在编辑器场景里,绘制过程中还没有生成 DOM,鼠标移动时需要实时判断光标在哪个热区附近、要不要吸附端点;运行时场景里,你也许需要做「热区重叠检测」或者「键盘导航」。命中检测是数据层面的兜底能力。

// core/hitTest.js const hitTest = { /** * 统一入口,按热区类型分发到具体检测函数 */ isPointInHotspot(point, hotspot, containerWidth, containerHeight) { const target = coordinates.toPixel(hotspot, containerWidth, containerHeight); switch (hotspot.type) { case 'rect': return this._inRect(point, target); case 'circle': return this._inCircle(point, target); case 'polygon': return this._inPolygon(point, target.points); default: return false; } }, _inRect(point, rect) { return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; }, _inCircle(point, circle) { const dx = point.x - circle.x; const dy = point.y - circle.y; return Math.sqrt(dx * dx + dy * dy) <= circle.radius; }, _inPolygon(point, polygonPoints) { // 射线法实现,从 point 向右发一条水平射线 let inside = false; for (let i = 0, j = polygonPoints.length - 1; i < polygonPoints.length; j = i++) { const pi = polygonPoints[i]; const pj = polygonPoints[j]; const intersects = ((pi.y > point.y) !== (pj.y > point.y)) && (point.x < (pj.x - pi.x) * (point.y - pi.y) / (pj.y - pi.y) + pi.x); if (intersects) inside = !inside; } return inside; }, };

射线法(Ray Casting)是多边形命中检测的经典实现:从目标点水平向右发一条射线,统计它与多边形边界的交点个数,奇数则在内部,偶数则在外部。代码里的intersects表达式同时处理了判断和边界条件,核心机制是利用了两个顶点y坐标与目标点y坐标的差值符号不同,来确定射线是否穿过了这条边。

这个模块在编辑器里用处特别大。绘制多边形时,每加一个顶点,就用命中检测判断当前鼠标位置是否落在已绘制的区域内,从而决定要不要闭合图形或者高亮提示——不用等渲染完成再做区域判断。

4. 编辑器模块实战:从鼠标绘制到 JSON 导出的完整链路

4.1 热区数据结构的统一约定

编辑器画出来的东西,最终要落到一份可以被渲染层直接消费的 JSON 上。三种类型的通用字段是id/type/x/y,矩形多width/height,圆形多radius,多边形多points。

// 一份标准的热区数据示例 [ { "id": "hotspot-001", "type": "rect", "x": 42.5, // 左上角 x 的百分比坐标 "y": 31.2, // 左上角 y 的百分比坐标 "width": 18.3, // 矩形宽(百分比) "height": 12.6, // 矩形高(百分比) "action": { "type": "link", "value": "/products/shoes/2024-001" }, "label": "春季限量款跑鞋" }, { "id": "hotspot-002", "type": "circle", "x": 70.8, "y": 44.1, "radius": 5.2, // 半径(百分比) "action": { "type": "tooltip", "value": "隐藏参数:鞋底缓震层" } }, { "id": "hotspot-003", "type": "polygon", "points": [ { "x": 20.1, "y": 55.3 }, { "x": 35.6, "y": 49.8 }, { "x": 40.2, "y": 62.7 }, { "x": 25.9, "y": 68.4 } ], "action": { "type": "lightbox", "value": "/gallery/sneakers-detail" } } ]

action字段是整个热区的「行为载荷」,渲染层拿到它只做一件事:按类型触发对应回调。这种设计把数据与行为解耦,运营配置热区时完全不关心跳转怎么实现,只填action.type和action.value。后续要加新行为类型(比如埋点上报、打开视频弹窗),扩展action的联合类型即可。

4.2 绘制交互的事件流设计

编辑器交互是典型的「按下 — 拖动 — 抬起」三段式。矩形和圆形的绘制逻辑几乎一样:按下时固定起点,拖动过程中实时预览,抬起时确定终点。

// editor/drawTool.js 片段,矩形绘制逻辑 class DrawTool { constructor(overlaySvg, options) { this.overlaySvg = overlaySvg; this.drawing = false; this.startPoint = null; this.previewElements = { rect: null }; this.onComplete = options.onComplete || (() => {}); } bindEvents() { this.overlaySvg.addEventListener('mousedown', (e) => { // 只响应左键,避免和右键菜单冲突 if (e.button !== 0) return; this.drawing = true; this.startPoint = this._toRelativePoint(e); this._createPreviewRect(this.startPoint); e.preventDefault(); }); this.overlaySvg.addEventListener('mousemove', (e) => { if (!this.drawing) return; const currentPoint = this._toRelativePoint(e); this._updatePreviewRect(this.startPoint, currentPoint); }); document.addEventListener('mouseup', (e) => { if (!this.drawing) return; this.drawing = false; const endPoint = this._toRelativePoint(e); const rectData = this._computeRectData(this.startPoint, endPoint); this._removePreview(); this.onComplete(rectData); }); } _toRelativePoint(e) { const svgRect = this.overlaySvg.getBoundingClientRect(); return { x: e.clientX - svgRect.left, y: e.clientY - svgRect.top, }; } }

_toRelativePoint是把鼠标在屏幕上的绝对位置,换算成相对 SVG 容器的本地坐标。这里用的是getBoundingClientRect()做一次减法,比createSVGPoint()那套换算逻辑更直接。mousedown和mousemove监听在 SVG 上,而mouseup必须挂到document——这保证鼠标拖出容器外再松开也能正确结束绘制,是一个资深的交互经验点。

最终回调出去的rectData是百分比坐标,不是像素坐标。换算发生在_computeRectData内部:

_computeRectData(start, end) { const imgW = this.baseWidth; // 图片 naturalWidth const imgH = this.baseHeight; // 图片 naturalHeight return { type: 'rect', x: (Math.min(start.x, end.x) / imgW) * 100, y: (Math.min(start.y, end.y) / imgH) * 100, width: (Math.abs(end.x - start.x) / imgW) * 100, height: (Math.abs(end.y - start.y) / imgH) * 100, }; }

用Math.min处理反向拖动(从右下往左上画),用Math.abs保证宽高恒为正数。这两个细节不做的话,用户向左或向上拖动时热区坐标会变成负数,渲染层拿到负宽高的矩形直接显示不出来——这是编辑器类工具最常见的翻车点。

4.3 多边形绘制的特殊处理

多边形绘制和矩形圆形的交互完全不同:不是一次拖动成型,而是逐点点击连线,双击或点击起点闭合。代码里需要维护一个顶点数组:

// editor/drawTool.js 片段,多边形顶点收集 _polygonToolState = { points: [], tempPolygonElement: null, }; addPoint(point) { this._polygonToolState.points.push(point); this._renderPolygonPreview(this._polygonToolState.points); // 如果点击位置接近起点(阈值 10px),自动闭合 const first = this._polygonToolState.points[0]; const dx = point.x - first.x; const dy = point.y - first.y; if (Math.sqrt(dx * dx + dy * dy) <= 10) { this._closePolygon(); } } _closePolygon() { const points = this._polygonToolState.points.slice(0, -1); // 去掉多余的终点 if (points.length < 3) { this._resetPolygonTool(); return; } const polygonData = { type: 'polygon', points: points.map((p) => ({ x: (p.x / this.baseWidth) * 100, y: (p.y / this.baseHeight) * 100, })), }; this.onComplete(polygonData); this._resetPolygonTool(); }

闭合阈值的判断放在addPoint里,用户体验上是「最后一个点点到差不多位置就自动闭合」的效果。注意_closePolygon把最后一个点slice掉了——因为那个点是闭合时误触的重复点。

命中检测在这里也派上用场:编辑器实时用射线法判断当前悬停的位置是否在已绘制的多边形内部,如果在,就给用户一个「可以闭合」的光标反馈,进一步降低误操作概率。

5. 避坑记录:图片热区插件落地最容易踩的 4 个坑

5.1 坑一:图片尺寸变化后热区全部偏移

现象:插件在富文本编辑器里调试一切正常,图片原尺寸 1600px,热区精确贴合。发布到移动端,图片变成宽度 375px,所有热区都偏了。

原因:坐标换算用了offsetWidth/offsetHeight作为基准。offsetWidth是元素的实际渲染宽度,它会受 CSS 影响——比如设置了max-width: 100%,图片在桌面端渲染成 1600px,移动端渲染成 375px,同一份代码在两端用不同的基准算热区,结果自然不一致。

解决:换算统一改用naturalWidth/naturalHeight。这两个属性返回的是图片源文件的真实尺寸,没有任何 CSS 干扰。渲染层再做一次「以原始尺寸为基准的百分比 → 当前容器像素」的转换。记住一句话:你把什么尺寸当基准存的,就永远用那个尺寸当基准算。

5.2 坑二:z-index 设置的坑——热区盖住了图片本身

现象:热区渲染出来了,但图片上的所有操作全部失效,图片本身绑定的点击事件永远触发不了。

原因:热区元素position: absolute放进一个容器,容器的z-index很高,热区元素默认层级高于图片;图片上原本有点击缩放等交互,被透明的热区 DOM 遮住了。透明元素依然占据层级空间,事件会命中它而不是图片本身。

解决:在热区容器上设置pointer-events: none,热区元素显式设置为pointer-events: auto。这样「事件穿透」只在没有热区的地方生效,有热区的地方正常捕获点击。这是热区叠加层的标准做法,不做的话一个页面有热区的地方图片永远点不到。

5.3 坑三:热区编辑器和页面滚动事件打架

现象:在移动端用手指绘制多边形时,手指一动页面就跟着滚,热区画得歪歪扭扭。

原因:绘制操作本质是手指在触摸屏上滑动,而touchmove是passive事件,浏览器认为默认行为(滚动)必须执行,不可能被preventDefault()取消。

解决:绘制进行时在容器上动态设置touch-action: none,绘制结束恢复原值。这个 CSS 属性告诉浏览器「这个元素上触摸操作由 JS 处理,不需要滚动」,绕开 passive 限制。同理,鼠标拖拽在桌面端不会和滚动冲突,但触摸端必须处理。如果项目把编辑器做成覆盖层,最佳实践是编辑模式下给根容器加overflow: hidden配合touch-action: none,双保险。

5.4 坑四:图片懒加载导致热区初始化位置错乱

现象:页面加载完成后,热区要等两三秒才出现,出现时位置比图片偏了一截;图片加载完成后热区又跳回正确位置。

原因:热区初始化时,图片还没加载完成,naturalWidth/naturalHeight是 0。这时候做百分比换算,得到一堆 NaN 或 0,渲染出来的热区位置全部错误。等图片真正加载完成,naturalWidth有了真实值,重新计算后热区位置又修正了。

解决:初始化顺序强行依赖图片load事件,而不是 DOMContentLoaded。代码必须先await图片的 load 完成,再拿到自然尺寸去初始化热区。另一个备选是轮询检查img.complete属性,但complete在缓存命中时会直接为 true 导致跳过等待,不如事件监听可靠。注意load事件在图片已缓存时可能不触发的边界情况,所以要在complete为 true 时直接继续初始化逻辑。

6. 二次开发:把插件接进任意框架的四个改造点

先说一个改造理念:这个项目的两个核心模块——坐标系统和命中检测——是纯函数集合,完全不依赖 DOM API,这是刻意设计的结果。二次开发时优先保持核心模块的纯净性,把 DOM 交互全部隔离在渲染层和编辑器层,这样不管外面包什么框架,核心逻辑一行不改,只替换渲染和事件绑定。

第一个改造点是「数据驱动的框架接入」。在 React 里用这个插件,正确的姿势是把热区数据作为 React state 持有,渲染层用一个useEffect监听数据变化,如果变化就调用hotspotInstance.setData(newData)。不要把热区数据存在插件实例内部,不然 React 的状态管理就会和数据源脱节,你没法在组件里直接根据热区数据联动其他 UI。同理在 Vue 里,把setData封装成watch的响应式入口。

第二个改造点是「事件钩子扩展」。原插件的 action 是内置的link/tooltip/lightbox三类,实际接入时业务方可能有自定义行为,比如点击热区上报埋点、触发弹窗组件、联动播放视频。我的改法是在Hotspot构造函数里加一个actionHandler注册表:

// 二次开发示例:自定义 action 处理器 const hotspot = new Hotspot({ container: '#image-wrapper', onAction: (hotspotData) => { const handlerMap = { link: () => window.open(hotspotData.action.value, '_blank'), track: () => trackEvent('hotspot_click', { id: hotspotData.id }), videoModal: () => openVideoModal(hotspotData.action.value), }; const handler = handlerMap[hotspotData.action.type]; handler && handler(); }, });

这样业务方只扩展handlerMap就能接入任意行为,不需要改动插件内部代码。这个模式比覆写onClick回调干净得多,能支持一个页面多种热区行为混合存在。

第三个改造点是「多边形精细编辑」。原插件多边形只能逐点绘制,运营想调整一个顶点的位置只能删除重画。这个功能不复杂,面向顶点做拖拽监听,拖拽中的命中检测交给_inPolygon判断哪个顶点离光标最近(阈值 8px),然后更新对应点的百分比坐标并触发重绘。这是二次开发里性价比最高的一块。

第四个改造点是「响应式断点适配」。如果同一张图在不同断位下需要不同的热区——比如桌面端鼠标悬停有 tooltip,移动端没有——那就在Hotspot.setData之前做一层数据预处理,根据视口宽度过滤掉不需要的热区。注意不要去改坐标数据,只过滤id列表,这样数据源保持干净,后续切换断点恢复也容易。

这个项目我从拆开读到改完一共花了两天。最值得记的一笔是把命中检测从渲染层抽出来的那次重构——在那之前我把点击判断写在 SVG 的click事件里,编辑器一用就崩,因为绘制过程中根本没有 DOM 可点。从那以后我每次做这类图形交互插件,都强制走一遍「纯函数核心 + 单层 DOM 渲染 + 事件隔离」的三层分离,再也没为类似问题返工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进