ECharts geo 地图动态 select 指定区域高亮实战
1. 先把需求拆明白geo 地图上的动态选中到底难在哪做 echarts geo 地图的朋友大概都有过这样的经历地图铺出来了颜色也调好了产品经理过来说点击左边列表里的省份右边地图上对应的区域要高亮而且要高亮成那种一眼就能看出来的橙色。听起来是不是特别简单可真动手的时候你会发现echarts geo 地图动态 select 指定高亮显示区域这件事涉及到组件选择、状态模型、事件回传、Action 派发四个环节任何一个环节没接上表现就是点了没反应或者高亮了但取消不掉。我先说结论ECharts 里的选中高亮不是靠你自己改颜色实现的而是靠内置的 select 状态 selectedMode 开关 dispatchAction 派发三件套。很多人第一反应是拿到点击事件后用setOption把某个区域的itemStyle.areaColor改掉这个思路能跑但代价很大——每次都要重建配置对象地图会闪一下而且和其他状态hover 强调、默认着色、数据映射互相打架。用原生 select 机制这些麻烦全都省了。这篇文章适合三类人一是刚接手数据可视化大屏、第一次用 geo 组件的前端同学二是被联动高亮需求反复折磨、想找个稳定写法的中级开发三是想把地图交互封装成公共组件的团队。我会把 geo 组件和 series-map 的 select 差异讲透给出可以直接抄的动态高亮代码再把我自己踩过的坑一条条列出来。顺手说个搜索上的小尴尬你在搜索引擎里敲 echarts select一半结果会跳到 SQL 的select语句、insert into select、select top 1000 * from ...这类内容上去想找前端的东西得加上 echarts geo、selectedMode 或者 dispatchAction 才有准头。这个我在最开始查资料的时候浪费了不少时间所以后面的关键词我都会写得尽量具体。1.1 静态着色和动态高亮本质是两套东西先区分两个容易被混为一谈的东西。静态着色是这个省的数据是多少我就给它对应色阶上的颜色一般交给visualMap或者直接在data里写itemStyle它的特点是颜色由数据决定重新渲染才变。动态高亮是用户此刻关注哪个区域颜色由交互状态决定跟数值没关系随时能开随时能关而且同一时刻可能有多个区域同时处于选中态。这两套逻辑如果是同一个区域优先级怎么排我的经验是select 状态的样式优先级高于数据着色。也就是说一个省哪怕数据色是浅蓝只要你把它选中了它就会显示成 select 里定义的橙色。所以设计稿里那个高亮色最好和数据色阶拉开明显差距不然在高值区域上根本看不出来。还有一点容易忽略高亮是有记忆的。用户点了 A 省A 省亮着这时候如果外部列表切换了分类重新走一遍数据渲染A 省的高亮是保留还是清掉这个必须和产品确认。默认情况下如果你只是更新数据而没有重置状态选中态是留着的。想要干净切换就得主动派发一次取消选中的 Action这个后面会写。1.2 geo 组件和 series-map到底该用哪个来做高亮这是最多人卡住的地方。简单粗暴的判断标准你的地图上还要叠散点、飞线、涟漪点或者要把地图当成一个坐标系来用 —— 选geo组件你的地图就是一张分区域着色的图每个区域带数值、带 tooltip不需要额外的坐标系 —— 选series-map两个都要 —— 可以同时存在但要知道高亮作用于谁。我见过不少人把series-map和geo叠在一起用然后发现点击地图只有一层高亮另一层纹丝不动就开始怀疑人生。原因很简单它们是两个独立的状态机。series 上的选中状态归 series 管geo 上的选中状态归 geo 管。你要么统一用一层承载交互要么在两边的 select 事件里互相派发 Action 做同步。就动态 select 指定高亮显示区域这个标题来说如果你的来源是外部程序控制的比如点列表、定时轮播、URL 参数带过来的区域名那么用哪一层承载高亮都行关键是你派发的 Action 类型要和图层对上。geo 用geoSelectseries-map 用select一字之差用错了就是毫无反应而且控制台连报错都不给你——这是最坑的地方。2. select 机制原理解析ECharts 怎么记住谁被选中了搞清楚原理后面所有问题都能自己推出来。ECharts 的图形元素或者说数据项有一套状态机理解它比背 API 有用得多。2.1 四层状态normal、emphasis、select、blur 的关系可以把它想成一个人的四种表情normal平时什么样没被任何人碰emphasis鼠标悬停上去的瞬间表现俗称 hover 态select被选中之后的表现是持久态鼠标移开也不会掉blur当有别的项被选中或高亮时其他项被冷落的样子。默认配置里emphasis是有样式的ECharts 会给一点加深效果select在没开启selectedMode之前根本不存在。这就是我配了select.itemStyle却什么也没发生的头号原因——不是样式写错了是这个状态压根没被激活。四层状态的样式是分开配的写法上{ itemStyle: { areaColor: #e8f3ff }, // normal基础态 emphasis: { // hover 态 itemStyle: { areaColor: #c6e2ff }, label: { show: true, color: #1a1a1a } }, select: { // 选中态需要 selectedMode 打开 itemStyle: { areaColor: #ff7a45 }, label: { show: true, color: #ffffff } }, blur: { // 被冷落态 itemStyle: { opacity: 0.5 }, label: { show: false } }, selectedMode: multiple // 关键开关 }注意select.label里的show: true。很多人只写了color发现区域亮了但名字没出来因为 label 默认是不显示的select状态不会帮你继承emphasis里的配置。状态样式之间是平级覆盖不是继承关系这一点务必记住。另外blur态在selectedMode为single或multiple时会有明显效果选中 A 省之后其他省进入 blur视觉上焦点非常集中。做汇报大屏的时候我经常故意把 blur 的 opacity 压到 0.4 左右效果比单纯点亮一个省好得多。2.2 geo 与 series 的 select 配置位置差异对比项geo 组件series-map选中开关geo.selectedModeseries.selectedMode选中样式geo.select/geo.regions[].selectseries.select派发选中type: geoSelecttype: select派发取消type: geoUnSelecttype: unselect派发切换type: geoToggleSelecttype: toggleSelect定位参数geoIndexseriesIndex事件名geoselectchangedselectchanged默认选中无声明式写法需派发data[].selected: true这张表建议直接存书签。我见过一次事故就是有人把geoSelect写成了selectseriesIndex也写了控制台不报错但是高亮永远不动查了两小时。因为select这个 Action 在派发时如果找不到对应的 series比如地图其实挂在 geo 上ECharts 会静默忽略。还有一个细节series-map支持在data里直接声明selected: true来做初始高亮这个非常方便页面一加载就带着状态。而 geo 组件没有这个声明式入口只能用chart.dispatchAction在setOption之后补一刀。geo 的变通办法是通过regions预设样式但那是样式不是状态从geoselectchanged事件里拿到的选中列表是不认它的——所以如果你需要状态一致性老老实实派发 Action。2.3 事件回传和 Action 派发构成一个闭环ECharts 的交互是双向的理解这个闭环联动就顺理成章上行用户操作 → 你的代码用户点击区域ECharts 内部状态变更然后抛事件。geo 抛geoselectchanged事件对象里有name当前操作区域和selected当前所有被选中项的数组元素是{name, selected}结构。注意selected返回的是全量选中列表不是增量的单选的场景它也是一个长度为 1 的数组别被selected[0]之外还有别的项给骗了。下行你的代码 → 图表你调dispatchAction图表按指令改状态并重绘。常用的三种// 选中某个区域geo 图层 chart.dispatchAction({ type: geoSelect, geoIndex: 0, name: 广东 }); // 取消选中name 支持数组可批量 chart.dispatchAction({ type: geoUnSelect, geoIndex: 0, name: [广东, 浙江] }); // 切换选中选中则取消未选中则选中 chart.dispatchAction({ type: geoToggleSelect, geoIndex: 0, name: 江苏 });这个闭环有个容易出问题的点事件回传触发你的代码你的代码又派发 ActionAction 又触发事件。如果不加控制就会形成循环。典型场景是列表 ↔ 地图双向联动点地图 → 事件里更新列表选中项 → 列表组件 onChange 又去调地图geoSelect→ 又触发事件……表现出来就是闪烁、卡顿甚至栈溢出。解决办法是在派发前打个标记位比如isSyncing true或者在一次同步后短暂忽略事件具体写法在第 4 章。注意派发geoSelect时如果selectedMode是single你传入两个 name 也只会生效最后一个并且在同一次派发里先选后取消最终只留一个。批量高亮一定要用multiple。3. 手把手搭一个能跑的最小工程原理讲完直接上代码。这一节我给一个完整可运行的页面骨架你复制到一个 HTML 文件里改一下地图数据的引用路径就能看到效果。3.1 地图数据准备与注册ECharts 5 之后官方包不再内置地图边界数据需要你自己注册。数据来源一般有两种项目里已有的 GeoJSON 文件或者团队内部数据平台提供的行政区划边界。不管从哪来使用前确认数据精度和来源满足你们项目的要求别随便从网上抓一份五年前的数据就用边界对不上很难看。注册代码很简单import * as echarts from echarts; // 假设你本地有一份 china.json import chinaGeoJson from ./geo/china.json; echarts.registerMap(china, chinaGeoJson);注册名china是自定义的后面配置里map: china要对上。同一个页面要显示多张地图比如中国 某省下钻就注册多个名字china、guangdong各注册一份互不干扰。如果 GeoJSON 是通过接口异步拉的注册时机要注意必须在setOption之前完成注册否则图表会报Map china not exists。异步场景我一般这样写async function init() { const geojson await fetch(/api/geo/china).then(r r.json()); echarts.registerMap(china, geojson); chart.setOption(myOption); }别用setTimeout猜时间异步就用await这个偷懒不值得。3.2 完整配置项逐行拆解下面这份配置是geo 组件 散点 单/多选高亮的组合注释我写得比较细const option { tooltip: { trigger: item, // tooltip 自动换行的小技巧后面第 5 章细说 formatter: function (p) { return p.name br/状态 (p.selected ? 已选中 : 未选中); } }, geo: { map: china, roam: true, // 允许缩放拖拽 zoom: 1.15, selectedMode: multiple, // 关键打开多选单选写 single label: { show: false, fontSize: 11 }, itemStyle: { areaColor: #e8f3ff, borderColor: #9dc4e8, borderWidth: 1 }, emphasis: { label: { show: true, color: #1a1a1a }, itemStyle: { areaColor: #c6e2ff } }, select: { label: { show: true, color: #ffffff }, itemStyle: { areaColor: #ff7a45, borderColor: #ff4d19, borderWidth: 1.5 } }, blur: { itemStyle: { opacity: 0.45 }, label: { show: false } }, // 针对个别区域单独覆盖 select 样式 regions: [ { name: 广东, select: { itemStyle: { areaColor: #2f8feb }, label: { show: true, color: #fff } } } ] }, series: [ { type: effectScatter, coordinateSystem: geo, // 挂在 geo 上 data: [ { name: 广州, value: [113.27, 23.13, 100] } ], symbolSize: 8 } ] };几处值得说的selectedMode放在geo里不是放顶层。写在顶层不起作用这是配置层级的常见错误。regions里可以给特定区域单独设 select 样式但注意这里只覆盖样式不改变状态。我上面的例子里广东是蓝色高亮其他省是橙色这种重点区域用专属色的做法在汇报场景里很常见。blur的 opacity 建议不要低于 0.4再低就是一片糊用户看不出地图轮廓了数据感知会断掉。3.3 用 dispatchAction 动态点亮指定区域页面加载完你想默认高亮两三个省就写chart.setOption(option); // 注意必须在 setOption 之后图表实例已经渲染完成 chart.dispatchAction({ type: geoSelect, geoIndex: 0, name: [广东, 江苏] });如果用的是series-map写法换成chart.dispatchAction({ type: select, seriesIndex: 0, name: [广东, 江苏] });这里有个实操心得name必须和 GeoJSON 里properties.name的值逐字符一致。我遇到过一次GeoJSON 里写的是广西壮族自治区代码里传的是广西结果派发下去毫无反应。排查办法是把地图数据里的 name 全遍历打印一遍跟你的业务数据做一次比对对不上的做个映射表function normalizeName(raw) { const map { 广西: 广西壮族自治区, 内蒙古: 内蒙古自治区, 新疆: 新疆维吾尔自治区 }; return map[raw] || raw; }这几十行映射代码能省掉你未来几小时的困惑非常值得。4. 真实业务里的四种动态高亮场景最小例子跑通之后真正的挑战在业务联动的细节上。下面四种场景我都在项目里落地过写法经过多轮迭代直接拿走改。4.1 外部列表与地图双向联动重点是防循环需求长这样左边一个省份列表点某一项地图上对应区域高亮反过来点地图列表对应项也要选中。麻烦在于两边都会触发对方的回调。我的做法是加一个同步锁只允许一个方向在某一时刻生效let syncing false; // 地图 → 列表 chart.on(geoselectchanged, function (params) { if (syncing) return; syncing true; const selectedNames (params.selected || []) .filter(item item.selected) .map(item item.name); listComponent.setValue(selectedNames); // 更新你的列表 syncing false; }); // 列表 → 地图 listComponent.onChange(function (names) { if (syncing) return; syncing true; chart.dispatchAction({ type: geoUnSelect, geoIndex: 0, name: all }); if (names.length) { chart.dispatchAction({ type: geoSelect, geoIndex: 0, name: names }); } syncing false; });geoUnSelect的 name 传all可以清空全部选中态这是官方支持的写法比记住旧列表逐个取消靠谱得多。别自己维护上一次选中了谁的数组迟早会漏。4.2 自动轮播高亮别用 setInterval 硬怼大屏常见需求每 3 秒自动高亮下一个省循环播放。我见过不少人用setInterval 计数器派发 Action能跑但有三个问题切到后台标签页时定时器继续空转用户手动点击时轮播和手点打架长时间运行后如果某次派发失败状态会错位。改进方案是在每一轮只依赖当前真实的选中列表来算下一个而不是靠内部计数器const allNames [北京, 上海, 广东, 江苏, 浙江, 四川]; let timer null; function playNext() { const current chart.getOption().geo[0].selectedMap || {}; const selected allNames.filter(n current[n]); const currentName selected[0]; const idx allNames.indexOf(currentName); const nextName allNames[(idx 1) % allNames.length]; chart.dispatchAction({ type: geoUnSelect, geoIndex: 0, name: all }); chart.dispatchAction({ type: geoSelect, geoIndex: 0, name: nextName }); } function start() { stop(); timer setInterval(playNext, 3000); playNext(); } function stop() { if (timer) { clearInterval(timer); timer null; } }注意这里用了chart.getOption().geo[0].selectedMap来读当前状态这是判断谁被选中了最可靠的方式比自己在外面记一份更稳。另外记得在页面隐藏时stop()切回来再start()用visibilitychange监听就行能省不少无谓的重绘。用户手动点击时怎么处理我的做法是监听geoselectchanged如果事件来自鼠标params.event存在就临时暂停轮播 10 秒再恢复体验上不会我刚点完就被切走。4.3 多选、全选与批量取消的状态同步多选场景的坑主要在全选上。全选不能一个个派发geoToggleSelect那样每次都要重绘一次40 个区域就是 40 次渲染卡顿肉眼可见。正确的做法是一次派发传数组// 全选 chart.dispatchAction({ type: geoSelect, geoIndex: 0, name: allRegionNames }); // 全不选 chart.dispatchAction({ type: geoUnSelect, geoIndex: 0, name: all });批量派发时有个实测结论一次传 60 个 name 和传 6 个 name渲染耗时差距大概在 3~4 倍但仍然是毫秒级完全可接受。真正慢的是派发 60 次这种写法千万不要。还有个细节geoSelect派发完成后会触发一次geoselectchanged事件里的selected是全量列表。你如果在这个回调里去更新侧边的统计数字比如已选 12 个区域总 GDP 是多少一定要用params.selected.filter(i i.selected)过滤因为没有被选中的项也会出现在数组里只是selected: false。这个结构我第一次见的时候也被绕了一下。4.4 和飞线、散点的联动高亮地图上有飞线的时候高亮一个区域往往希望对应的飞线也亮起来其他的暗下去。ECharts 的lines系列没有 select 状态它不支持selectedMode所以这条路走不通得用换数据的方式function highlightRegion(name) { const relatedLines lineData.filter(l l.from name || l.to name); chart.setOption({ series: [{ type: lines, data: relatedLines }] }); }这是setOption的合并更新只影响lines系列地图的选中态不会丢。飞线的视觉强化我一般靠加大lineStyle.width和提升opacity来对比而不是改颜色因为改颜色在小屏幕上区分度很低。5. 踩坑记录高亮不生效的八个典型原因这一节是我实际遇到问题的汇总按出现频率从高到低排。遇到点了没反应按表从上往下查基本五分钟内能定位。现象可能原因解决办法点击完全无反应selectedMode没开或层级写错geo 写在geo内map 的写在series内派发 Action 无反应Action 类型或定位参数写错geo 用geoSelectgeoIndexmap 用selectseriesIndex派发无反应但类型对区域名和 GeoJSON 的 name 不一致打印地图数据 name 全量比对加映射表高亮样式不生效样式写在了emphasis里select必须独立配置高亮后文字不出现只改了颜色没写show: trueselect.label.show true高亮闪烁或死循环事件与派发互相触发加syncing锁取消不掉高亮手动维护了选中列表漏项用name: all清空地图整体偏色blur的 opacity 设太低不低于 0.4除此之外还有几个更隐蔽的版本差异。ECharts 5.1.0 才开始支持geo组件的selectedMode更早的版本只有series-map支持。如果你在 5.0 或者 4.x 上折腾 geo 的高亮那真的是白费功夫先升版本。项目里如果用的是低版本 CDN而且短期内动不了那就把交互层换成series-map来实现别硬啃。同一页面多实例串扰。一个页面上有两个地图实例事件监听写在了echarts全局对象上而不是实例上会导致 A 图的操作触发 B 图的逻辑。监听一定要挂在实例上chart.on(...)不是echarts.on(...)。容器尺寸变化后错位。这个和第 6 章相关地图容器如果用了 rem 或者百分比窗口变化后地图没重绘高亮的区域位置和鼠标点击位置会错开表现为我点的是广东亮的是江苏。这不是 select 的问题是没调resize()。tooltip 自动换行没生效。地图上的 tooltip 内容稍微长一点就撑成一条横线很丑。ECharts 的 tooltip 不会自动换行需要在formatter里手动插换行或者给extraCssText加white-space: normal; word-break: break-all;并且限制max-widthtooltip: { trigger: item, extraCssText: max-width: 260px; white-space: normal; word-break: break-all; }这几行加完tooltip 的观感提升非常明显尤其是区域名带全称的时候。注意排查 select 相关问题控制台基本不会给你任何报错。所以高效的办法是在派发前后各打一次日志把chart.getOption().geo[0].selectedMap打出来。这个对象能直接告诉你状态到底有没有进去比猜快一百倍。6. 工程化打磨性能、缩放与代码复用最后聊点把功能做稳做久的经验。这部分不是必答题但做完之后后面维护你的人会感谢你。6.1 容器缩放与 pxtorem 失效有个经典组合拳问题项目里用pxtorem做移动端适配结果发现它对 ECharts完全不起作用地图该多大还多大。原因很简单ECharts 把画布尺寸算好之后画在canvas里后续 CSS 层面的根字号变化图表是感知不到的。解决办法只有一个在容器尺寸变化后主动调用chart.resize()。const wrap document.getElementById(map-wrap); const ro new ResizeObserver(() { chart.resize(); }); ro.observe(wrap); // 组件销毁时记得断开 // ro.disconnect();用ResizeObserver比监听window.resize好因为它能捕捉到父容器因为布局变化导致的尺寸改变比如侧边栏折叠、Tab 切换显示。我用window.resize的时候就被侧边栏折叠坑过折叠后地图右半边是白的。6.2 高频派发的性能处理轮播、鼠标滑过、拖拽联动这些场景里dispatchAction可能被触发得非常频繁。虽然单次开销不大但叠加起来还是会影响流畅度。我的处理原则是第一能不派发就不派发。判断状态是否真的要变比如用户点了一个已经选中的区域如果你用的是geoSelectECharts 内部还是会走一遍状态比对不如在业务层先查一下selectedMap状态没变直接 return。第二合并同帧内的多次操作。用requestAnimationFrame做一次批量提交let pending null; function scheduleSelect(name) { if (pending) cancelAnimationFrame(pending); pending requestAnimationFrame(() { chart.dispatchAction({ type: geoUnSelect, geoIndex: 0, name: all }); chart.dispatchAction({ type: geoSelect, geoIndex: 0, name }); pending null; }); }第三避免setOption和高亮混用。如果你在一次交互里既要更新数据又要改高亮先setOption再派发 Action顺序反了的话setOption有可能会把选中状态覆盖掉表现就是亮了一下又灭了。6.3 封成一个可复用的高亮控制器同一个项目里地图可能出现在三四个页面每个页面都复制一遍上面这些逻辑改需求的时候你会疯。我一般封一个不到 100 行的小控制器职责很清晰只管高亮不管业务数据。class GeoHighlighter { constructor(chart, options {}) { this.chart chart; this.geoIndex options.geoIndex ?? 0; this.mode options.mode ?? geo; // geo | series this.syncing false; } get actionTypes() { return this.mode geo ? { select: geoSelect, unselect: geoUnSelect, toggle: geoToggleSelect, indexKey: geoIndex } : { select: select, unselect: unselect, toggle: toggleSelect, indexKey: seriesIndex }; } select(names) { const a this.actionTypes; const params { type: a.select, name: names }; params[a.indexKey] this.geoIndex; this.syncing true; this.chart.dispatchAction(params); this.syncing false; } clear() { const a this.actionTypes; const params { type: a.unselect, name: all }; params[a.indexKey] this.geoIndex; this.chart.dispatchAction(params); } replace(names) { this.clear(); if (names names.length) this.select(names); } getSelected() { const opt this.chart.getOption(); const holder this.mode geo ? opt.geo[this.geoIndex] : opt.series[this.geoIndex]; const map holder.selectedMap || {}; return Object.keys(map).filter(k map[k]); } }有了它业务页面里就三行const hl new GeoHighlighter(chart); hl.replace([广东])。切换模式只要改一个mode参数geo和series都能用前面表格里那些差异全被封装掉了。收尾分享一个我自己反复验证过的小习惯每次接入新地图数据先写个临时的脚本把 GeoJSON 里的 name 全部列出来跟业务侧的字典做一次 diff 打印。这一次性花掉的五分钟能挡掉后面 80% 的高亮不生效问题。另外把那句name: all的清空写法记牢我见过太多人费劲维护选中历史数组最后都是自己给自己找麻烦。地图这东西看着花哨真正难的地方全在状态同步上把状态当唯一真相源代码自然就简单了。