ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

景区导览地图进阶:自定义图标、聚合标记与动态位置实现

景区导览地图进阶:自定义图标、聚合标记与动态位置实现

这期内容正好是系列教程里最容易被跳过、但实际交付时必须补上的一环。上一期我们把景区的基础POI标记点铺了上去,坐标、标题、图标都能显示了,看起来已经像那么回事。但真的把导览系统拿给客户或游客试用的那一刻,问题就来了:卫生间、餐厅、停车场、医务室全是同一套样式,人群站一起,游客根本分不清哪个是哪个;热门景点和几十个手工作坊的小点位挤在一团,缩放到城市级视图时密密麻麻;再有就是游览电瓶车的实时位置、路线轨迹这种东西,普通静态标记点完全做不了。

“其他类型标记点”指的就是这些:自定义图标标记、文字标签、聚合标记、动态位置标记,再配合线状路线和面状区域,把一个只有点的地图进化成真正能用的景区智慧电子导览系统。这篇是ebmap tour系列的第六篇,我会把我实际做项目时用到的实现逻辑、代码结构和踩坑记录全部摊开来讲,适合已经做完基础标记点、正准备扩展交互层的人参考。

1. 为什么普通标记点撑不起一个导览系统

1.1 服务设施、动态位置、聚合展示:三个最现实的需求

先回想一下前几期我们做了什么:地图加载、行政区域展示、简单POI标记。那套流程做内网演示没有问题,但要对外发布,很快就发现三个真实需求。

第一个是服务设施识别。游客在地图上找卫生间,说按一下标记点弹出文字“卫生间”就行,其实不够。人的视觉系统更依赖形状和颜色差异,你需要给卫生间用蓝色、餐厅用橙色、停车场用绿色、医务室用红色,而且图标要一眼能看懂。全部用同一个默认图钉是肯定不行的。

第二个是位置重叠。一个景区可能有上百个文化点位,比如小型的非遗展示点、文创铺子、打卡点,它们往往集中在古镇核心街区,地图缩放级别低的时候,点与点之间只差几像素,点击都不好点。这时候需要一个聚合机制:重叠的点自动合并,显示“这里有23个景点”,放大后再散开。

第三个是动态信息。导览系统做到后面,景区会要求“显示游览电瓶车当前到哪了”,或者“给游客展示推荐路线”。这已经不是静态点位的问题,而是一个需要不断更新坐标的标记点,和一条根据坐标轨迹生成的线。

这三个需求正好对应了“其他类型标记点”这个主题。与其等需求来了再临时查方案,不如在做项目早期就把这些能力整理成通用模块,后面复用成本很低。

1.2 先定需求,再选标记点类型:一张对照表

很多人在这一步容易走急,看到地图引擎功能多,就什么东西都往地图上加。真正靠谱的做法是先列需求,再对照技术能力选型。我把我常列的一张表放在下面,几乎可以直接抄。

使用场景需要的标记类型数据字段要求是否必须
卫生间、餐厅等公共设施自定义图标标记点图标标识、名称、经纬度是
景点名称显示文字标签标记点标题、经纬度按需
大量点位重叠聚合标记点点位类型、数量统计建议
电瓶车、游客实时位置动态标记点最新经纬度、方向按需
推荐游览路线线状覆盖物坐标数组、颜色建议
服务区边界、危险区域面状覆盖物边界坐标数组按需

按这个表去和需求方对齐,基本能避免“做了一堆功能全没用上”的尴尬。而且你会发现,除了动态标记点之外,其他的数据模型都不复杂,核心就是把原来的POI数组的字段扩展一下,加上type、icon、visible等配置就行。

2. 自定义图标标记点:给卫生间、餐厅、停车场换个明显标识

2.1 图标标记点的两个核心参数:尺寸和锚点

自定义图标标记点比默认标记点多做一件事:设置Icon。别小看这一步,参数配错了,图标要么大得挡住半个景区,要么图标尖角对不上真实坐标。

以我们项目里用的地图可视化平台为例,创建一个带自定义图标的标记点,核心代码大概是这样的:

const marker = new Marker({ position: [117.123, 36.456], icon: new Icon({ url: './icons/restroom.png', size: [32, 32], anchor: [16, 32] }) }); map.add(marker);

这里面最容易忽略的是anchor,也就是锚点。什么是锚点?锚点就是图标上“真正对准经纬度坐标”的那个像素位置。大多数图标的视觉焦点在底部尖角,比如图钉,所以锚点要设置成图片宽度的1/2和高度的整高。如果锚点不设置,有的引擎默认以图片中心作为锚点,结果就是:你明明想把点位标在卫生间门口,图标底部却压到了目标点几十米外。缩放级别越大,错位越明显。

另外,尺寸参数建议直接按设计稿的物理大小换算。地图单位默认是像素,16到36像素之间的图标在手机端和PC端观感最舒服。太大会盖住相邻标记,太小又点不准。

2.2 把标记数据统一成数组,用循环批量创建

在景区导览系统里,单独写几十个Marker创建语句是最笨的做法。正确的姿势是把所有标记点数据整理成一个数组,然后循环创建。这样设计的好处是,后续要改图标、加点击事件、做筛选显隐,全部只需要改数据源或循环体内部,不用去翻几段不相干的代码。

我习惯用这样一个结构:

const poiData = [ { id: 1, name: '游客中心', lng: 117.123, lat: 36.456, type: 'service', icon: './icons/service.png', intro: '提供咨询、寄存和售票服务' }, { id: 2, name: '山泉餐厅', lng: 117.128, lat: 36.460, type: 'food', icon: './icons/food.png', intro: '本地农家菜,位处竹林旁' }, { id: 3, name: '北门停车场', lng: 117.115, lat: 36.462, type: 'parking', icon: './icons/parking.png', intro: '小车位约200个,距游客中心500米' } ]; poiData.forEach(item => { const marker = new Marker({ position: [item.lng, item.lat], icon: new Icon({ url: item.icon, size: [30, 30], anchor: [15, 30] }) }); marker.__data = item; map.add(marker); });

把业务数据挂到标记点实例的自定义属性上,是我做项目时的习惯。后面处理点击弹窗的时候,直接从marker本身就能拿到数据,不需要再用索引去数组里找了,逻辑会清爽很多。

2.3 文字标签标记点:什么时候该用,什么时候不该用

文字标签标记点就是地图上一段带背景的纯文字,比如“竹溪长廊”“观景台”这种。它适合用在两种地方:一是某些区域不适合放图标,放文字更干净;二是地图缩放到中高层级时,给所有图标统一显示名称。

实现方式一般有两种:用引擎自带的Label类型,或者用文字背景组成自定义图标。前者更推荐,因为自带避让逻辑和字号控制,后者在点位多的时候会造成文字互相遮挡。

我遇到过一种情况:景区要求在部分木结构建筑坐标上只显示“非遗工坊”四个字,不允许显示图标,因为图标会挡住手绘建筑的细节。这个场景用Label正好:

const label = new Label({ position: [117.130, 36.458], content: '非遗工坊', offset: [0, -12], fontSize: 14, fontColor: '#ffffff', backgroundColor: 'rgba(40, 60, 80, 0.7)' }); map.add(label);

offset是一个需要注意的参数。文字默认中心对准坐标点,如果不往上偏移,文字会盖住坐标点本身。一般设置成[0, -12]到[0, -16],让文字悬浮在点位上方,这样视觉效果更接近“标注”。另外中文字符串比较宽,backgroundColor的padding如果引擎没暴露,就通过content外面包一层字符串加空格模拟,实测非常管用。

3. 用聚合标记点解决点位扎堆,顺便搞懂图层显隐

3.1 聚合标记的原理和参数

聚合标记点不是一种神奇的新元素,它本质上是把许多标记点按屏幕距离合并成一个“数字徽标”。当地图缩小,点位间的像素距离小于阈值时,它们被合并;当地图放大,距离拉开了,再重新拆开。整个过程由聚合图层自动完成,不需要你自己维护合并状态。

实现通常有两种路径。

第一种是引擎自带聚合图层。这个路径最省事,你只需要把原始点位数据传进去,设置聚合半径,然后给聚合后的按钮写一个样式渲染函数:

const clusterLayer = new ClusterLayer({ data: poiData, radius: 60, renderer: (count, points) => { return new Marker({ content: `<div class="cluster-num">${count}</div>`, position: getClusterCenter(points) }); } }); map.add(clusterLayer);

第二种是引擎不带聚合图层,需要自己实现。核心思路是:监听地图的moveend和zoomend事件,获取当前视野范围内的点位,按经纬度投影到屏幕坐标,如果两点屏幕距离小于60像素就归到同一组,然后计算组内中心点,创建一个临时标记显示数量。这个方案代码量在100行上下,但能加深你对地图投影原理的理解,建议有时间的小伙伴自己写一次。

3.2 分组显隐控制:游客按需求开关图层

聚合是一个视觉层面的技巧,它解决的是“拥挤”问题。但导览系统还有一个很常见的需求:分图层显示。比如游客可以选择只看餐厅、只看卫生间、只看景点。这个需求本质上是图层分组和显隐控制。

我的做法是把所有标记点分为基础组和扩展组:

  • 基础组:景点入口、游客中心,默认开启。
  • 设施组:卫生间、餐厅、停车场、医务室,默认关闭或用户手动勾选。
  • 动态组:电瓶车位置、实时轨迹,默认开启。

分组之后,对每个标记点做visible控制,或者更简单,把组内标记点压入同一个数组,切换时统一setVisible(true/false)。如果你的地图引擎支持LayerGroup,那就更好了,直接把LayerGroup的显隐属性整个切掉:

const facilityGroup = new LayerGroup(); poiData.filter(item => item.category === 'facility').forEach(item => { const marker = buildMarker(item); facilityGroup.addMarker(marker); }); map.addLayerGroup(facilityGroup); // 切换按钮 document.querySelector('#toggle-facility').addEventListener('change', (e) => { facilityGroup.setVisible(e.target.checked); });

这里要特别提醒:如果同时使用了聚合图层,显隐控制和聚合是相互影响的。聚合图层通常是基于导入的数据自动重新生成,数据源变了,聚合结果才会变。所以要么你在切换分组时把数据源过滤一遍再重建聚合图层,要么你干脆分组聚合,每个类别单独一个聚合图层。后者实现稍微复杂,但运行时不会出现“关闭了餐厅图层,餐厅点位还残留在聚合数字里”的问题。

4. 会动的标记点:车辆位置更新和轨迹回放

4.1 动态点位更新:setPosition的使用场景与动画过渡

景区导览系统做到进阶,动态点位是一个亮点功能。最简单的应用是电瓶车的实时定位:后台每3秒推送一次坐标,前端拿到坐标后把车辆图标移动到新位置。

基础做法很直接:

const busMarker = new Marker({ position: [117.123, 36.456], icon: busIcon }); map.add(busMarker); // 假设由WebSocket或轮询接口持续推送位置 socket.on('bus-position', (data) => { busMarker.setPosition([data.lng, data.lat]); });

但这样实现,车辆图标在地图上的移动是“跳”过去的,没有连贯感。尤其是两帧之间距离比较大的时候,看起来像瞬移,体验很糟糕。要改善,需要补一段线性插值动画。

我写了一个最小可用的运动函数:

function smoothMove(marker, from, to, duration = 500) { const start = performance.now(); const dx = to[0] - from[0]; const dy = to[1] - from[1]; function step(now) { const progress = Math.min((now - start) / duration, 1); const lng = from[0] + dx * progress; const lat = from[1] + dy * progress; marker.setPosition([lng, lat]); if (progress < 1) { requestAnimationFrame(step); } } requestAnimationFrame(step); }

这不是什么高深理论,就是requestAnimationFrame配合简单的线性插值。经纬度在小范围内可以近似等比插值,景区这种几公里范围内的移动完全够用。如果追求真实,可以用二次贝塞尔曲线,模拟车辆转弯的弧线,不过优先做线性版本,看到实际效果再决定要不要优化。

4.2 路线轨迹与沿线回放:把“去过哪儿”画出来

轨迹回放是另一种形式。它把车辆经过的所有坐标点保存下来,生成一条线,同时让一个动态标记沿着这条线循环移动。

轨迹线的实现靠线状覆盖物:

const trackLine = new Polyline({ path: trackPoints, color: '#1e80ff', weight: 4, opacity: 0.85 }); map.add(trackLine);

沿线回放的逻辑稍微绕一点:你需要把轨迹按累计距离分段,让标记点在每两点之间的运动时间与该段距离成正比,这样速度才均匀。简单的实现可以用以下伪逻辑:

  1. 把轨迹点数组按每两个点计算距离,得到总距离;
  2. 设置一个固定的播放速度,比如每秒50单位距离;
  3. 根据当前进度找到当前所在线段,计算线段内的偏移坐标;
  4. 更新标记点位置,同时把已走过的部分画成高亮颜色。

实现上我会用requestAnimationFrame循环,每次更新一个progress变量,直到progress到达1。

这个功能做出来以后,配合信息窗,游客可以点击回放按钮,看到“你今天的游览路径”,代入感很强。但性能上要注意:轨迹点过多时,整条Polyline渲染耗时会上升。一般保存轨迹时先做抽稀,例如每10秒或每15米取一个点,画出来的线路视觉上依然平滑,处理速度却快一个量级。

5. 不只是点:线状路线和面状区域和点标记打配合

5.1 游览路线线:把游客引导做进地图

一个导览系统如果只有点,游客很难在陌生景区形成全局观。线状覆盖物的加入,是为了把点与点之间的逻辑关系表达出来,最常见的用法是推荐游览路线。

比如我们把“入口—镜湖—竹海—茶室—出口”作为一条步行路线,用一条蓝色实线标出来。实现代码和上面的轨迹线类似,只是path数据是静态的:

const routePath = [ [117.123, 36.456], [117.127, 36.458], [117.130, 36.462], [117.135, 36.465] ]; const routeLine = new Polyline({ path: routePath, color: '#33a06f', weight: 4, opacity: 0.7, lineStyle: 'dashed' }); map.add(routeLine);

这里有个设计技巧:把推荐路线设为虚线,把正在回放的实时轨迹设为实线。这样游客一眼就能区分“建议走的路线”和“已经走过的路”,不用额外解释图例。

5.2 面状覆盖:服务区边界和电子围栏

面状覆盖物就是常说的多边形,主要用在两个场景:一是表达区域范围,比如露营区、烧烤区、儿童活动区的边界;二是电子围栏,比如湖边危险区域,游客接近时系统弹出警告。

多边形在代码层面是一个坐标数组的封闭区域:

const warningZone = new Polygon({ paths: [ [117.120, 36.450], [117.124, 36.453], [117.128, 36.450], [117.125, 36.446] ], color: '#e64c4c', weight: 2, fillColor: 'rgba(230, 76, 76, 0.2)' }); map.add(warningZone);

边界颜色和填充色要区分开:边框更实,填充更透。这是为了让底图地形还能看得见,也避免多块区域叠加时变成一团深色。

配合点标记,区域可以这样做:一个区域面加上区域出入口的点标记,点击出入口标记弹窗显示区域开放时间。这样点、线、面三者之间就形成了一套完整的空间表达体系,而不是孤立地各画各的。

6. 给标记点加弹窗交互:从“能看见”到“能点开”

6.1 点击标记点展示详情信息窗

前面几期做完,导览系统里的标记点还是静态的“图钉”。这期内容的最后一块拼图是交互:用户点击标记,弹出一个信息窗,展示名称、简介、开放时间、图片等信息。这个功能叫InfoWindow,几乎每个地图引擎都有。

最简单的绑定逻辑是这样:

marker.on('click', () => { const infoWindow = new InfoWindow({ content: `<div class="poi-card"> <h4>${item.name}</h4> <p>${item.intro}</p> <p>开放时间:${item.time || '全天'}</p> </div>`, position: [item.lng, item.lat] }); infoWindow.openOn(map); });

这里有个体验细节:弹窗打开的位置最好在标记点正上方的偏移处,而不是在标记点上。因为信息窗默认会把坐标点盖住,游客再点击其他点位时,事件容易被弹窗拦截。我习惯在InfoWindow配置里加一个offset参数,把弹窗往上推30到40像素。

6.2 多个标记点事件绑定中的闭包陷阱

批量给标记点绑事件,八成的新手会在这里翻车。问题出在循环里使用var或者直接使用循环变量:

for (var i = 0; i < poiData.length; i++) { const marker = createMarker(poiData[i]); marker.on('click', () => { infoWindow.show(poiData[i]); // i 一直是最后的值 }); }

你点了所有的标记,弹窗永远显示最后一个点的数据。这是JavaScript闭包的老问题,i在循环结束后停留在数组长度,所有回调共享同一个i。

解决办法有几种:

第一种,把var换成let:

for (let i = 0; i < poiData.length; i++) { ... }

这是语法层面的修正。第二种更可靠,把数据直接挂在marker实例上,事件回调从marker自身取数据,这也是我在前面2.2节埋下的伏笔:

poiData.forEach(item => { const marker = createMarker(item); marker.__data = item; marker.on('click', function () { showInfoWindow(this.__data); // 不要用箭头函数,this指向marker }); });

注意回调里用普通function而不是箭头函数,否则this不会指向marker,拿不到__data。用这种方式,不管循环怎么写,数据都不会串。

7. 我在这个项目里反复踩过的几个坑,写给你避雷

7.1 坐标偏移:GPS设备坐标和地图坐标不是一套

这个问题在景区现场调试时被放大了很多倍。辛辛苦苦用手机GPS随手采了几个坐标点,导到系统里,点位全部偏移了几十米到几百米,尤其是靠近建筑墙根的位置,误差更明显。

原因多数是坐标系不统一。全球卫星定位拿到的原始坐标是WGS84坐标系,而我们常用的国内地图引擎默认使用经过偏移的国测局坐标系。两者不经过转换直接渲染,偏移量在城市区域可能达到几十米。

处理办法也很简单:一是用地图引擎自带的坐标拾取器手动采集点位,采集出来的坐标直接就是引擎所需坐标系;二是如果数据来源确实是WGS84设备,要在导入系统的过程中加一个坐标转换函数,不要放在前端运行时去转,那样不仅慢,还会影响标记点精确度。

7.2 图标锚点偏移导致标记位置不准

这个坑我在2.1节已经提过,但实际项目中仍然会出现。有一种情况是设计师给的图标底部并不是视觉中心,而是多了一圈留白,按[width/2, height]设置锚点后,点位依然不准确。

我在项目里的排查方式是分两步:先打开地图的调试模式,显示标记点精确位置十字线,然后调整anchor数值,观察图标底部与十字线的贴合情况。如果图标的视觉重心在底部偏右,就微调锚点为[width/2 - 2, height]。这类误差2到3像素,缩放到18级时才看得到,但强迫症项目不能留。

7.3 字体和文字宽度让标签样式不稳定

文字标签的样式在不同终端上经常表现不一样。PC端正常,手机端字体变大,背景框包不住文字;或者中文字符显示不完全。这个问题本质上是因为字体渲染宽度不可控。

比较稳妥的解决方式是给Label设置最小宽度、最大宽度,或者把背景框样式做厚一点,留出明显内边距。如果引擎支持富文本,可以在content里套CSS:

<div class="map-label" style="white-space: nowrap; padding: 2px 6px;">非遗工坊</div>

这样就绕开了引擎内置默认样式。还有一个值得记住的点:文字标签不要放在缩放级别极低时显示,否则几十条标签叠在一起会完全不可读。按缩放级别控制标签显隐,是提升地图观感性价比最高的做法。

7.4 数据多了变卡:渲染层面的性能优化方向

最后一个问题,也最让人头疼的,是数据量上来之后的性能下降。几个标记点没感觉,但把整个景区所有设施、店铺、打卡点加起来,到两三百个的时候,拖动地图已经能感到掉帧了。

这个问题的优化方向通常有三个:

一是聚合。把几百个点位变成几十个聚合块,屏幕上的绘图压力直线下降,这是最直接的优化。

二是视野内渲染。不要一次性把全景区所有标记点都加到地图上,仅向地图添加当前视野范围内的点位,然后监听moveend事件,动态增删视野内的标记点。这个策略在点位超过千级时格外重要。

三是降低单个标记的渲染成本。使用自定义图标的SVG或CSS样式替换大尺寸PNG图片,尤其在移动端,大图标的解码和重绘开销是卡顿主因。

我在项目中最终的配置是:聚合 + 视野内渲染 + 图标压缩到32像素内。实测两千个点位的地图拖动流畅度在手机端能接受,这已经远超普通游客使用场景的负荷了。

这几个坑看起来很小,但每一个都会在真机演示的节骨眼上冒出来。坐标偏移被客户当场指出过,图标锚点问题在截图评审时被设计方挑剔过,标签字体不一致则是让页面显得“不精致”的暗伤。把它们提前写在前面,正是在给自己后续减少工作量。做这个系列项目的过程中我最大的体会是:小功能背后藏着的大坑远比想象中多,把所有标记类型一次规划好,比上线后返工划算得多。

RELATED READING

延伸阅读

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