简介插件面向网页前端开发者与页面设计人员基于jQuery实现图片热区交互可在图片上自由绘制矩形、圆形及不规则区域并为每个热区绑定跳转链接或触发事件常用于在线地图、产品展示、交互式图像教程等场景帮助解决图片区域点击定位与事件分发的问题。压缩包共8个文件、约209KB涵盖2个JavaScript文件、CSS样式、HTML演示页、PNG示例图片、XML配置与Markdown说明插件逻辑、页面示例和样式资源相对完整便于直接查看运行效果并进行二次修改。已有2275人学习具备一定参考热度。源码注释较详尽示例支持拖放调整热区位置与尺寸、自定义链接和外观样式也可在IDE中预览编辑读者能够据此掌握图片热区的绘制与绑定思路快速将交互热区嵌入页面或扩展自定义事件、样式与页面模型适合需要实现图像交互的中初级前端开发者参考。1. 图片热区不是“点一下”那么简单先看清它到底解决什么问题一张设备面板照片贴进页面用户点“电源键”要弹出参数说明点“散热口”要跳到维护手册——这类需求在业内就叫图片热区。原生map配area也能凑合但坐标写死成 px、图片一缩放就整体错位更别提悬停高亮、可视化编辑和移动端手势。图片热区 JS 插件真正做的事是把“图上的可点区域”拆成数据描述、渲染层、命中检测三块让热点跟着图片走而不是跟着屏幕走。它适合做产品图标注、设备示意图、教学图示、展厅导览的前端尤其是那种需要后台拖拽画区域、前台只读展示的项目。下面按坐标体系、命中检测、配置落地、避坑、进阶五段拆开讲。2. 热区坐标体系从图片像素到容器百分比的三层换算热区插件翻车十次有八次出在坐标上。图片在页面里被 CSS 缩放、被 flex 挤压、被object-fit裁切你拿到的clientX是视口坐标存进库里的却是原图像素中间隔着好几层换算。这一章把这三层坐标彻底理清后面所有实现都建立在这套约定上。2.1 为什么不能直接写死 px 坐标最容易上手的做法是照抄原生的area coords120,80,300,200直接在原图像素上标注。本地调好一切正常一上生产就出问题设计稿给的是 1920 宽的图页面容器只有 1200图片被width:100%压到 1200热区却还按 1920 的原图坐标画整体往右下偏移、面积还偏大。换一张 2 倍图偏移量又变一遍。根本原因是原图像素是“绝对量”而页面展示是“相对量”。合格的解法是引入归一化坐标把每个点的 x 除以图片原始宽度、y 除以原始高度得到 0 到 1 之间的两个小数。这份数据与图片分辨率无关、与容器宽度无关、与设备像素比无关存进数据库以后前端拿到多大的容器都能反算回来。我一般把归一化坐标保留 4 位小数在 4000px 宽的原图上误差约 0.4px肉眼完全看不出存储体积也比存原图像素小得多。// 三层坐标换算原图像素 - 归一化 - 显示像素 const coord { // 原图像素转归一化存库用这个 toNorm(px, natural) { return (px / natural).toFixed(4); }, // 归一化转显示像素渲染时用这个 toDisplay(norm, rendered) { return norm * rendered; }, // 鼠标事件坐标转归一化采集和命中都用这个 eventToNorm(clientX, clientY, rect) { return { x: ((clientX - rect.left) / rect.width).toFixed(4), y: ((clientY - rect.top) / rect.height).toFixed(4) }; } };natural取自img.naturalWidth和img.naturalHeight这是图片文件本身的尺寸不受 CSS 影响。rect取自img.getBoundingClientRect()是图片在视口里的实际显示框。eventToNorm把两者串起来是所有采集逻辑的唯一入口我要求团队里任何人做热点相关代码都必须调它不许自己手写减法——手写一次就漏一次rect.left。2.2 三种几何形状的数据结构设计热区不是只有矩形。设备图上的圆形指示灯、人体图上的不规则器官、地图上的行政区划需要的形状完全不同。插件通常支持三种矩形、圆形、多边形数据结构要一次性设计到位否则后面加形状要改表、改渲染、改命中检测牵一发动全身。形状字段归一化方式典型场景rectx, y, w, h四个值都除以对应边按钮、面板、矩形标签circlecx, cy, r圆心除以宽高r 除以短边指示灯、圆形端口polygonpoints: [[x,y],...]每个点除以对应边器官、区域、不规则件{ id: hs_07, type: polygon, points: [[0.1200, 0.3400], [0.1820, 0.3010], [0.2210, 0.4150], [0.1450, 0.4520]], label: 散热口, action: { type: tooltip, content: 建议每 6 个月清灰一次 } }圆形的半径要除以“短边”而不是宽或高很多实现直接除以宽结果图片是 16:9 的横图时圆在纵向被压扁成椭圆视觉上歪得很明显。多边形则要求点数组顺序一致全部顺时针或全部逆时针后面射线法判定依赖这个约定。action字段我习惯做成可扩展对象而不是裸字符串方便以后加“跳转链接”“播放视频”“埋点事件”等类型不用改表结构。2.3 坐标采集把鼠标点换算成图片原始坐标编辑器里画热区的过程本质是一串鼠标事件加上坐标换算。矩形是按下拖拽到松开圆形是按下定圆心拖拽定半径多边形则是一次点击定一个点、双击或回车收尾。三种采集的骨架相同坑都藏在细节里。function startPolygonCapture(img, onDone) { const stage img.parentElement; const pts []; let preview null; function norm(e) { const r img.getBoundingClientRect(); // 用相对图片的偏移不缓存 rect滚动后依然准确 return { x: ((e.clientX - r.left) / r.width).toFixed(4), y: ((e.clientY - r.top) / r.height).toFixed(4) }; } function onClick(e) { const p norm(e); // 落在图片外就直接丢弃避免负坐标脏数据 if (p.x 0 || p.x 1 || p.y 0 || p.y 1) return; pts.push([p.x, p.y]); preview preview.setAttribute(points, pts.map(v v.join(,)).join( )); } function onDblClick() { stage.removeEventListener(click, onClick); stage.removeEventListener(dblclick, onDblClick); if (pts.length 3) onDone({ type: polygon, points: pts }); } stage.addEventListener(click, onClick); stage.addEventListener(dblclick, onDblClick); }norm里每次点击都重新取getBoundingClientRect()这是刻意的页面滚动、容器动画、键盘弹出都会让图片位置变化缓存一次 rect 是坐标错位最常见的来源。toFixed(4)在这里不只是为了省体积也顺手消除了浮点噪声同一位置点两次能存成同一个值方便去重。双击收尾时要清掉 click 监听否则双击会先触发两次 click、多存两个点这种“多一个点”的 bug 在多边形自交排查里能折磨人一下午。旁边再加个preview元素实时画线编辑体验会好很多这部分属于交互优化不影响数据正确性。3. 插件核心实现命中检测、事件分发与缩放适配数据存好了下一步是“用户点下去怎么知道点中了哪个”。这一章讲命中检测的三种算法、缩放下的坐标映射以及重叠热区的事件分发。算法本身都不复杂难的是在非等比缩放、object-fit裁切、移动端手势这些边界条件下仍然算得准。3.1 矩形/圆形/多边形的命中检测算法矩形最简单点在左上角和右下角之间即可。圆形算圆心距离的平方与半径平方比较避免开方。多边形用射线法从点击点向右发一条水平射线数与多边形边界的交点个数奇数在内、偶数在外。三种算法都在归一化坐标空间里跑输入是同一个点输出是布尔值。// 命中检测点和形状都在归一化空间0~1 const hitTest { rect(p, s) { return p.x s.x p.x s.x s.w p.y s.y p.y s.y s.h; }, circle(p, s) { const dx p.x - s.cx; // aspect 是图片显示宽高比非等比拉伸时用于修正 const dy (p.y - s.cy) * s.aspect; return dx * dx dy * dy s.r * s.r; }, polygon(p, s) { let inside false; const pts s.points; for (let i 0, j pts.length - 1; i pts.length; j i) { const xi pts[i][0], yi pts[i][1]; const xj pts[j][0], yj pts[j][1]; // 标准射线法判断水平射线与边是否相交 if ((yi p.y) ! (yj p.y) p.x ((xj - xi) * (p.y - yi)) / (yj - yi) xi) { inside !inside; } } return inside; } };aspect只在图片被非等比拉伸时需要正常情况下归一化空间里的圆就是圆。多边形那段里(yi p.y) ! (yj p.y)是半开半闭区间判断能避免射线正好穿过顶点时的重复计数这是射线法最经典的边界问题。还有一个隐含前提多边形不能自交。编辑器允许用户乱点采集时最好加一道自交检测或者至少在保存时提示一次否则命中结果会很玄学——同一个点点左边判定在外、点右边判定在内。多边形自交检测用两两线段求交即可O(n²) 在几十个点的规模上完全够用。3.2 缩放与响应式下的坐标映射图片尺寸变化时热区图层必须同步。监听窗口resize是老办法容器因布局变化而改变宽度时它不触发容易漏。现代做法是ResizeObserver盯住图片元素本身尺寸一变就重算。function mountLayer(img, layer) { function sync() { const rect img.getBoundingClientRect(); const nw img.naturalWidth, nh img.naturalHeight; // 计算 object-fit:contain 时的实际绘制区域 const scale Math.min(rect.width / nw, rect.height / nh); const drawW nw * scale, drawH nh * scale; const offsetX (rect.width - drawW) / 2; const offsetY (rect.height - drawH) / 2; layer.style.position absolute; layer.style.left offsetX px; layer.style.top offsetY px; layer.style.width drawW px; layer.style.height drawH px; } // 图片加载完才能拿到 naturalWidth img.complete ? sync() : img.addEventListener(load, sync); new ResizeObserver(sync).observe(img); }这段的关键是把“图片实际绘制区域”和“图片元素占位”区分开。用了object-fit: contain时图片元素可能 800×600实际画面只有 800×450上下各留 75px 黑边。归一化坐标是相对“画面”的所以 svg 图层必须精确覆盖那 800×450 的区域而不是整个 800×600。scale取宽高缩放比的较小值正是 contain 的定义。如果项目里用的是object-fit: fill那就没有黑边offset 直接为 0把这段逻辑简化掉即可。很多现成插件默认按 fill 实现一换到 contain 的布局里就全体偏移这是选型时要问清楚的一点。3.3 事件冒泡、层级与 tooltip 定位热区可能重叠比如一个大区域下压着几个小按钮。分发时要决定谁先响应我的做法是渲染时记录绘制顺序命中检测时倒序遍历后画的在上层命中即停。这样用户在编辑器里调顺序前台表现就能跟着变。layer.addEventListener(click, (e) { const rect img.getBoundingClientRect(); const p { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height }; // 倒序后绘制的在上层命中即停避免事件穿透 for (let i hotspots.length - 1; i 0; i--) { const s hotspots[i]; if (hitTest[s.type](p, s)) { s.action s.action(); return; } } }); // tooltip 定位贴边时自动翻到另一侧 function placeTooltip(tip, anchorX, anchorY) { const vw window.innerWidth, vh window.innerHeight; const bw tip.offsetWidth, bh tip.offsetHeight; let left anchorX 12, top anchorY 12; if (left bw vw - 8) left anchorX - bw - 12; // 右边放不下翻左边 if (top bh vh - 8) top anchorY - bh - 12; // 下边放不下翻上边 tip.style.left left px; tip.style.top top px; }return是关键少了它事件会继续往下传用户点一次弹出三个说明的情况就会出现。tooltip 用position: fixed配合视口坐标比相对定位更好控制翻转逻辑offsetWidth在隐藏元素上读出来是 0所以要先显示再定位或者用visibility: hidden占位测量。移动端还要处理 300ms 点击延迟和“点透”问题现代浏览器加了viewport里user-scalableno就基本不触发延迟但如果产品要求保留缩放就得用touchstart配合preventDefault同时注意别把整页滚动也拦掉。4. 配置项与使用流程一个可抄的落地示例前面的都是积木这一章把它们装成一个能直接调的组件并给出从标注到上线的完整流程。如果你只想尽快在项目里跑起来看这一章就够如果你要改插件源码回看第 2、3 章。4.1 初始化参数表与默认值组件用工厂函数加实例方法的风格比直接改原型链好维护也方便一个页面挂多个实例。参数类型默认值说明srcstring必填图片地址hotspotsarray[]归一化热区数据editablebooleanfalse是否开启可视化编辑highlightobject见下高亮色与描边placementstringautotooltip 定位策略onSelectfunctionnoop命中回调拿到热区对象onSavefunctionnoop编辑态保存回调拿到全量数据const viewer new HotspotViewer(#stage, { src: /assets/panel.jpg, hotspots: [], editable: false, highlight: { fill: rgba(0, 120, 255, 0.22), stroke: #0af, strokeWidth: 1.5 }, onSelect(hs) { console.log(命中, hs.id, hs.label); } }); viewer.setData(data); // 编辑态渲染已有热区 viewer.setMode(edit); // 切换编辑态 const result viewer.getData(); // 导出可直接入库 viewer.destroy(); // 卸载时记得调用清掉 ResizeObserver参数里最容易踩的是strokeWidth。它在 svg 里默认跟着viewBox缩放如果 viewBox 是0 0 1 1strokeWidth: 1会画出一像素粗的框但实际是“归一化空间里的 1”等于整张图的宽度直接把整块区域抹黑。正确做法是设vector-effect: non-scaling-stroke或者把 viewBox 设成0 0 1000 1000这类固定比例再换算。这个细节后面避坑一章还会再提一次。4.2 从标注到上线的完整流程流程分五步每一步都有对应的校验动作别跳步。上传原图读取naturalWidth/naturalHeight写进图片记录表后续换算都靠它。打开编辑态用户用鼠标画区域每次操作产生的都是归一化数据。保存前执行validateHotspots过滤掉越界、类型非法、点数不足的条目。前台只读模式渲染建立一个hotspots数组按绘制顺序倒序命中。上线前在真机上过一遍桌面缩小窗口、手机横竖屏切换、双指缩放确认热区贴着图形走。第 5 步最容易被省略却是唯一能暴露 letterbox 偏移问题的环节。我的习惯是在测试环境加一个调试开关鼠标移动时右下角实时回显当前归一化坐标肉眼一比就知道错没错位。4.3 数据持久化与后台编辑对接热区数据和图片是一对多的关系建议单独建表用图片 id 关联而不是塞进图片表的 JSON 字段。单独建表的好处是能按热区维度做统计比如哪个热点点击最多。function validateHotspots(list) { return list.filter((h) { if (![rect, circle, polygon].includes(h.type)) return false; if (!h.id || !h.label) return false; if (h.type polygon) { if (!Array.isArray(h.points) || h.points.length 3) return false; return h.points.flat().every((n) typeof n number n 0 n 1); } const nums Object.values(h).filter((v) typeof v number); return nums.length 0 nums.every((n) n 0 n 1); }); }过滤越想界的数据不是洁癖而是渲染层的自保一个x: 2.3的多边形点会让 svg 图形飞出容器还可能撑出横向滚动条用户看到一个莫名其妙的空白区域根本想不到是热区数据的问题。入库前卡一遍比自己写一堆防御渲染便宜得多。编辑态还建议加个“撤销”采集时每步操作压入栈比事后手改坐标省事。5. 避坑与排查五条踩过的坑条条都有代价这一章全是血泪经验现象、原因、解决三件套写全。遇到问题先按这张清单对一遍八成能定位。5.1 坐标类问题坑一换了图片尺寸热区整体偏移。现象把 1920 的图换成 2560 的版本热区还在老位置而且偏得越来越离谱。 原因数据库里存的是原图像素换图后原图尺寸变了但数据没跟着变。 解决统一存归一化坐标换图后不碰数据。如果历史数据已经是像素写个迁移脚本按老图尺寸反算x oldX / oldNaturalWidth跑完把原图尺寸字段一起更新。坑二object-fit: contain下热区往上偏了一截。现象图片上下有黑边热区画在画面上但实际点击位置要往上偏一段才命中。 原因svg 图层铺满了整个图片元素框而画面只在中间那块两者高度不一致。 解决按 3.2 节的scale/offset算法把图层收缩到实际绘制区域。把这段封装成syncLayerRect所有涉及坐标的地方都调它不许各写各的。5.2 交互与移动端问题坑三手机上点一次触发了两次动作。现象说明弹出来又被关掉或者跳了两次页面。 原因touchstart和click都绑了处理函数移动端浏览器为了兼容两者都会触发。 解决统一只监听click或者只在touchstart里preventDefault后走自己的逻辑不要两条路并行。用事件委托挂在图层上别给每个图形单独绑。坑四滚动页面后热区位置对不上。现象页面没滚动时正常往下滚一段再点判定全乱了。 原因缓存了getBoundingClientRect()的结果滚动后没更新。 解决每次事件里现取现用或者在scroll里同步更新缓存并加节流。我倾向于现取getBoundingClientRect本身够快没必要为省这点开销引入缓存失效问题。坑五多边形有的地方点得中有的地方点不中。现象同一个区域左半边正常右半边死活没反应。 原因多边形自交射线法在交叉点附近判定翻转一半在内一半在外。 解决采集时做自交检测并提示用户重画保存前再校验一次。已经入库的脏数据用两两线段求交扫一遍把自交的多边形挑出来人工修。6. 进阶用 SVG 叠加层做高亮与手势适配热区跑起来之后体验上的差距就体现在高亮和手势。渲染层我一直坚持用 svg 而不是 div 或 canvasdiv 画不了多边形canvas 要自己管重绘且没法用 CSS 过渡svg 天生带viewBox、自带描边填充、还能用stroke-dasharray做描边动画对热区这种“几何图形为主”的场景几乎是最优解。悬停高亮别直接改fill属性用 CSS 过渡更顺滑。给每个图形加classhs样式里写transition: fill .18s ease, stroke-opacity .18s easehover时把填充透明度从 0.22 提到 0.38描边从半透明变实。想强调某个热点可以用stroke-dasharray配合keyframes做一圈走马灯式描边比闪烁不刺眼。移动端缩放手势是另一个高频需求。双指缩放时不要改viewBox那会让图形跟着重新映射、坐标检查全乱正确做法是给最外层容器加transform: scale()配合transform-origin热区图形跟着容器一起缩放命中检测仍在归一化空间里进行什么都不用改。代价是缩放后描边会变粗用vector-effect: non-scaling-stroke加回去。// 手势缩放只改容器 transform不动 viewBox let scale 1; stage.addEventListener(wheel, (e) { e.preventDefault(); scale Math.min(4, Math.max(1, scale - e.deltaY * 0.001)); stage.style.transform scale(${scale}); // 命中检测用的还是归一化坐标无需跟随 scale 调整 }, { passive: false });验证方法我都用一个笨办法在调试模式里把鼠标的归一化坐标实时打在角落再用编辑器里“显示网格”把 viewBox 画成 10×10 分格比对肉眼确认热区顶点刚好压在原图特征点上。这一套走下来比事后对着用户反馈猜坐标错在哪快得多。说个教训。早些年做一个设备图项目热区坐标是直接按原图像素写死的测试阶段一切正常上线第二天运维换了一张分辨率更高的图所有热点集体往下偏了 40 像素。排查了半天最后定位到就是坐标没归一化。从那以后我每次接热区相关的需求第一件事就是把eventToNorm和toDisplay写进工具库强制所有坐标走一遍换算再也不敢图省事直接存 px。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?