ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

数据可视化:项目分布地图增加项目数量标识的完整实践

数据可视化:项目分布地图增加项目数量标识的完整实践

做管理驾驶舱一类的数据可视化项目时,对“项目分布地图”这块的印象格外深。地图画出来了,背景色、边界线、涟漪效果都有了,可业务方看了一会儿,指着某个区域问:“这里到底有几个项目?”我当时愣了一下——肉眼能看出颜色深浅,但色块不能读出一个精确的数字。这也正是“项目分布地图设计之增加项目数量标识”这个需求的核心价值:在分布地图上,把每个区域的项目数量明确标出来,让看图的人第一时间拿到信息,而不是靠色差去猜。这篇文章,我打算完完整整讲一遍做这个功能的全过程,从需求拆解、方案选型,到数据聚合、地图渲染、数量标注,再到踩坑和优化。如果你正在做大屏、管理驾驶舱、运营看板,或者刚开始接触数据可视化,想搞懂地图上的数据和图形是怎么绑定的,这篇应该对你有用。

1. 项目背景与需求拆解

1.1 只有底色的地图不是好地图

一开始拿到的设计稿里,项目分布地图就是一张普通的行政区域图,区域被填充成不同深浅的颜色,旁边配了一个图例,写明“颜色越深代表项目越多”。从视觉层面看这个图很完整,但从信息传递层面看,它有很大的缺口。

色块能表达相对大小,却表达不了绝对数值。比如一个区域深蓝色,一个区域浅蓝色,普通人只能判断“左边比右边多”,却说不清是多了几十个还是多几百个。更麻烦的是,如果两个区域的颜色接近,用户会开始反复对比色卡,然后继续猜。最终就是:图好看,但是不顶用。

增加项目数量标识,本质上就是把“数值”打到地图上,让色块承担区域对比,让数字承担精确表达。二者结合起来,用户扫一眼就能得出两个结论:这个区域多不多,以及具体是多少。

1.2 数量标识的三类使用场景

我在做过的项目里总结过,数量标识在地图上的应用场景大概能分三类。

第一类是区域统计地图。这是最常见的,一个区域可能是一个业务大区、一个园区或者一个行政区,我们要展示这个区域内有多少个项目、多少条工单、多少个客户。这种场景适合在区域中心或固定位置放数字标识。

第二类是点位统计地图。项目不是按区域平均分布的,而是落在具体的坐标点上,比如建筑工地、门店、基站。如果每个点都打一个标签,地图会乱成一锅粥,所以更常见的做法是聚合到区域维度显示数量,或者做点位聚合热力效果。

第三类是下钻联动地图。用户点击某个区域,地图钻到下一级,从片区到园区,从园区到单个项目。这时候数量标识要跟着层级变化,第一级显示大区总数,第二级显示园区数量,第三级可能就显示项目名称和状态了。

我这次做的项目属于第一类和第三类的结合:首页显示各大区的项目总数,点击进入后显示该大区下面的各分区域数量,再点击可以查看具体项目列表。增加数量标识是第一步,也是后面下钻逻辑的基础。

1.3 这一次的具体平衡点

需求听起来简单,但内部有一个平衡点:信息量多了会糊,少了没意义。

一开始业务方要求把“项目数量”和“项目经理”都放在地图上,我评估了一下,一个区域密密麻麻写两行内容,图面会非常拥挤,尤其是小区域,文字根本放不下。后来沟通下来,地图上只保留数量,项目经理信息挪到下钻后面的列表页。这是做地图可视化很重要的一个原则:地图上承载的信息必须克制,它负责空间定位,具体明细交给表格或卡片。

我建议做同类题目的朋友,先跟业务方把“地图上只放什么”和“地图下钻后才放什么”这两类内容切分清楚,不然很容易做一个什么都想塞、什么都看不清的地图。

2. 技术选型与标识形式设计

2.1 地图渲染方案怎么选

地图可视化没有标准答案,关键看使用场景。我这次先列了几个候选方案,逐一比较后才定的。

一个是基于传统 GIS 的离线地图方案,好处是底图专业、坐标精确,支持各种图层叠加,坏处是引入成本高,前端要维护地图服务,针对一个内部管理页面来说偏重。

一个是调用第三方在线地图 API,比如常见的地图开放平台,好处是底图现成,卫星图、路网图都有,坏处是依赖外部服务,而且像内部项目管理系统这种数据敏感的场景,把项目坐标打到公共底图上,无论是数据安全还是用户体验,都面临额外的顾虑。

一个是基于开源图表库配合 GeoJSON 渲染,即把行政边界的 GeoJSON 数据注册到图表库中,用地图系列和散点系列来画区域和数量。好处是渲染链路短、可控性高,方便在同一个画布里同时管理数据和交互事件,也方便做成纯内网部署。缺点是底图风格需要自己定义,边界数据要靠自己维护。

从成本、数据安全、开发效率三个维度衡量,我选了 GeoJSON 这条路。实际开发中的应用是:项目地图的底图采用内部维护的业务分区边界数据,而不是依赖外部在线服务,这样既能配合内网环境部署,也方便后面做自定义样式。

2.2 数量标识的四种常见形态

同样是加数量,不同的形态在视觉上的效果完全不一样,这里我拆成四种常用做法。

第一种是“纯文本标签”,直接在区域的中心位置写一个数字。优点是轻量、简单、精准,一眼就能读出来。缺点是区域小的时候文字会重叠,也没有面积大小的直观反馈。

第二种是“气泡点”,用圆形的大小表示数量多少,圆中心再写数字。优点是既有面积对比,又保有了精确数字,这是我最常用的强化方式。缺点是多个不均匀分布点凑在一起时,气泡会互相遮挡。

第三种是“标记图标”,比如图钉、旗帜加数字。好处是视觉指向性很强,适合点位不是特别密集的项目。坏处是图标会占据较大部分画面,项目数量如果很多,图面负担比较重。

第四种是“聚合小组件”,常见于地图缩放,缩小时显示区域内项目总数,放大后拆开成若干个独立标记。这种方案交互体验好,但实现复杂度最高,需要额外的聚合计算逻辑,通常用于点位级地图,我这次用的弱化版聚合。

2.3 我最终选定的标识组合

综合上面分析,我最终采用了“区域着色 + 中心数字标签”的组合方案,同时对数量较多的区域额外叠加一个气泡点。

具体解释一下:每个区域根据项目数量映射到不同的色阶,这是第一层,负责直观感受;区域中心显示数字,这是第二层,负责精确取值;数量特别大的区域,圆气泡更大更显眼,这是第三层,负责引导视觉重点。三层叠在一起,既不会像纯文本那样干巴巴,又不会像图标那样挤占空间。

数量差异太大时,还可以设置一个简单的分段策略:小于 10 的项目用 1 倍大小气泡,10 到 50 用 1.5 倍,大于 50 用 2 倍。这样能避免个别大规模区域的点把周围小区域的优势区全盖住。

3. 数量标识落地实现:从数据到地图

3.1 先把数据按区域聚合成统计口径

技术方案定了,第一步就是把项目数据聚合到区域维度。

我最初看到的原始数据长这样:一张项目表,里面一条条记录了项目名称、所属区域、项目状态、负责人。而地图需要的数据是“区域 + 项目数量”,字段结构完全不同,所以要先做一次聚合。

为了规范统计口径,我先跟业务确认了数量定义:是按全部项目统计,还是只统计“进行中”和“筹备中”的项目;要不要把已结束的项目剔除。这个口径如果不统一,后面显示的数字会被业务质疑,返工成本极高。

确认口径后,我在接口层写了一段聚合逻辑,核心就是按区域分组计数。假如数据在数据库里,一条 SQL 就能算清楚:

SELECT region_code, COUNT(*) AS project_total FROM project_info WHERE project_status IN ('PROCESSING', 'PREPARING') GROUP BY region_code;

如果是前端拿到的已经是扁平数组,也可以这样处理:

const projectList = [ { name: '项目 alpha', region: '华东A区', status: 'PROCESSING' }, { name: '项目 beta', region: '华北B区', status: 'PREPARING' }, { name: '项目 gamma', region: '华东A区', status: 'FINISHED' }, ]; // 按区域分组,只统计需要的状态 const regionStat = projectList .filter(item => ['PROCESSING', 'PREPARING'].includes(item.status)) .reduce((acc, item) => { acc[item.region] = (acc[item.region] || 0) + 1; return acc; }, {}); console.log(regionStat); // 输出类似:{ '华东A区': 1, '华北B区': 1 }

注意一个细节:一定要拿“区域编码”聚合,而不是拿“区域名称”聚合。区域名称经常有别名、简称、空格、全半角差异,比如有的数据里写“华东A区”,有的写“华东 A 区”,如果直接拿名称作为 key,一个区域会被劈成好几个部分。聚合完成后,再通过编码转名称来得到展示用名称。

3.2 区域名称与地图边界对齐

聚合完数据,第二个要命的环节来到我面前:区域名称和地图边界的 name 到底对不对得上。

无论你用的是公开的行政区划 GeoJSON,还是公司内部的业务边界数据,每个区域都绑定了一个 name 字段。而业务系统的区域名称和地图数据里的 name,经常对不上。

我的处理办法是建立一个映射关系表。假设地图边界数据里的名称是“华东A区”,但项目数据里的名称是“华东大区- A”,那就在接口层或前端做一次映射,统一转换成地图能识别的标准名。

// 名称归一化映射 const nameMapping = { '华东A区': 'A区', 'A区(华东)': 'A区', 'EAST-A': 'A区' }; function normalizeRegionName(rawName) { if (nameMapping[rawName]) return nameMapping[rawName]; return rawName.trim(); }

这一步极易出问题,也是最容易被忽视的。我见过不少项目,地图上明明有区域,但颜色没变、数字不显示,最后查到原因就是 name 多了一个空格。这种问题体验非常差,一定在联调阶段就遍历一遍所有区域,逐个核对 map 数据里的 name 和聚合数据里的 key 是否正确对应。

3.3 注册地图并渲染底层

名称对齐以后,就可以注册地图边界数据并完成底层渲染了。

我用的是开源图表库,注册方式很直接:把 GeoJSON 数据注册成某个自定义地图名,然后把 series 的 type 指定为 map,map 字段指向注册名即可。

import * as echarts from 'echarts'; import regionGeoJson from './region.json'; // 注册自定义地图 echarts.registerMap('projectRegionMap', regionGeoJson); const chart = echarts.init(document.getElementById('mapContainer')); chart.setOption({ tooltip: { trigger: 'item', formatter: (params) => { // 没有值的地图 hover 时不做展示 if (params.value == null) return ''; return `${params.name}<br/>项目数量:${params.value} 个`; } }, series: [{ type: 'map', map: 'projectRegionMap', roam: true, data: regionData, // [{ name: 'A区', value: 86 }, ...] label: { show: true, color: '#fff', fontSize: 14, formatter: (params) => { if (params.value == null) return ''; return `${params.name}\n${params.value} 个`; } }, itemStyle: { areaColor: '#17324d', borderColor: '#3f7e9c', borderWidth: 1 }, emphasis: { label: { color: '#ffd166', fontWeight: 'bold' } } }] });

这段配置已经把最核心的底层结构表达出来了:GeoJSON 提供边界,data 数组提供数量,label 提供数字标识,itemStyle 提供视觉样式。运行时你会看到一张区域区分明、鼠标悬停能显示数量的地图,但此时数字标识还停留在“能显示”的阶段,没有完全解决遮挡和可读性问题。

3.4 加数字标识的核心配置

如果只需要一个最朴素的“区域中心数字”,上面那段配置里的 label.formatter 已经够了。但实际项目里我进一步扩展成气泡 + 数字的组合型标识。

气泡点要求把数量转换成坐标点,并映射成可视化图形的大小。从实现上来看有这么两种常见做法。第一种是再叠加一个散点系列,把每个区域的中心坐标放到 series-data 里,用 symbolSize 映射数量;第二种是直接对区域 label 加强样式。我试下来,叠加散点系列这种方式更灵活。

业务区域的数量值分布往往不均匀,有的区 20 个项目,有的区 80 个项目,如果不做任何压缩处理,最大的气泡会比最小的大好多倍,图面失衡。我通常做一个开方映射,让半径不要跟数量线性绑定。

const scatterData = Object.keys(regionStat).map(name => { // 假设 reigonGeoCenter 保存了区域的中心坐标 return { name, value: [ reigonGeoCenter[name].lng, reigonGeoCenter[name].lat, regionStat[name] ] }; }); chart.setOption({ series: [{ type: 'map', map: 'projectRegionMap', data: regionData, label: { show: false } }, { type: 'scatter', coordinateSystem: 'geo', data: scatterData, symbolSize: (val) => { // 开方映射,削弱极端值的影响 return Math.max(10, Math.sqrt(val[2] || 0) * 4); }, itemStyle: { color: 'rgba(255, 180, 60, 0.75)', borderColor: '#fff', borderWidth: 1 }, label: { show: true, formatter: (params) => { return `${params.value[2]}`; }, color: '#fff', fontSize: 12, position: 'center' } }] });

这里有个关键点:散点和区域着色同时存在,需要把同一区域的两种视觉编码对齐。我给单个区域做的数量上限是几百,区域内还有一个气泡,二者表达同一个数值,视觉一致性需要特别关注,否则用户会觉得数据对不上。

3.5 颜色、字体与视觉层次的处理

地图不是一个独立的画布,它的观感和整体大屏主题绑定。我把样式调整分成三块。

色阶连续性:区域底色采用同一个色系,从浅到深连续过渡,避免跳色产生“两类数据”的错觉。如果业务没有特殊要求,我倾向用单色渐变而不是红绿渐变,红绿渐变很容易在色弱用户那里变成不可读的噪声。

文字层级:区域名称和数字标识应该在视觉上区分主次。我的做法是:地图底部区域名称用浅色小字,数量数字用更亮的颜色和更大字号。这样用户第一眼读到的是数字,想看名称时再往下看,不会两行字抢来抢去。

透明度处理:散点标识和区域填充同时存在,我给散点设置了半透明效果,不让它完全遮挡下面的区域底色。透明度太实会很生硬,太虚又看不清,一般我调到 0.7 左右,再开一个浅色描边把气泡轮廓勾出来。

4. 性能、交互与细节优化

4.1 地图卡顿与数据量过大的问题

数量标识刚加上去时,整个页面渲染还可以,但把接口里的历史数据全量放进去后,明显卡顿。原因不在数据量本身,而在于我一次性把所有项目点都塞给了地图。

地图渲染最怕一种情况:数据不是区域统计值,而是上千条带坐标的具体项目点。如果每个点都渲染成一个图形,画布上的元素会快速膨胀,即使是地图库做了 canvas 合成,也会感受到明显的交互迟滞。

我当时的解决思路是分两层。一层是“统计层”,进入首页地图时,只展示聚合后的区域数量,数据量一般就是几个到几十个区域,渲染毫无压力。另一层是“明细层”,点击下钻后再通过异步接口拉取当前区域的点数据或列表数据,一次只渲染一个区域的数据,即使该区域一两百个项目,也控制在一个可接受的范围。

这种做法对后端接口设计有要求:不能一次性把全部明细数据返给前端,最好提供两个接口。一个是统计接口,返回各区域聚合数量;另一个是明细接口,接收区域编码参数,返回该区域的项目列表或坐标点。分区拉数据,地图性能问题能减掉一半。

4.2 标签重叠与避让策略

第二个优化点是文字重叠。

当相邻两个区域都是小面积区域时,它们各自的数字标识很可能叠在一起,看起来像一团乱码。尤其是处于地图中部的密集区域,这个问题最让人头疼。我试过三种处理办法。

第一种是缩小字号。简单高效,但不能根治,字号小到 10px 以下时整体阅读性又会下降。

第二种是调整 label 位置,给每个区域配置不同的偏移量。这个方法能做,但工作量巨大,每个区域的 offset 都要人工调,还影响后续维护。

第三种是只在大区域和重点区域显示数量,小区域悬停时通过 tooltip 查看。这个办法看起来“妥协”,但实际体验最好,因为小区域在地图上的空间本身就有限,强行塞数字只会牺牲整体清晰度。

我最后用的是“分级显示”:数量前几名的区域始终显示数字,其余区域只有 hover 时才通过 tooltip 展示。这样地图画面清爽,重点区域依旧一目了然,兼顾精确和美观。

4.3 点击下钻与地图联动

地图增加数量标识后,用户第一个下意识动作就是点击数字。

最初版本我把点击事件绑在整个地图上,点击任意区域都能进入该区域的详情页。后来发现有两个体验问题:一是用户可能只想 hover 看一下总量,不想跳转;二是每次点击都会触发跳转,频繁误触。

我的调整是把交互拆成两段。第一段,hover 地图区域时,tooltip 显示数量和简要统计,不触发任何跳转。第二段,用户单击区域时,先判断是不是准确的 click,再判断该区域是否已经展开过;如果是第一次进入,就加载下一级区域数据并更新地图;如果已经展开,则在地图下方联动展示项目列表。

联动逻辑大概是这样的:

chart.on('click', (params) => { if (!params.name) return; // 请求该区域的下一级或明细数据 fetch(`/api/region/projects?region=${encodeURIComponent(params.name)}`) .then(res => res.json()) .then(data => { // 更新地图统计值或填充下方列表 updateMap(data.regionStat); updateProjectList(data.projectList); }); });

下钻要记得加“返回上一级”的按钮或功能,不然用户一层层点下去,会陷入没有出口的困境。我一开始没加返回导航,直接被业务方打返工单了。

4.4 暗色大屏和移动端的适配细节

项目数量标识的显示效果,在不同场景下要面对的挑战不同。

大屏场景通常背景偏暗,地图边界线要稍亮一些,数值字体要偏亮色系。如果背景是亮色的,比如内网管理后台的浅色页面,还继续用白色字体,会看不清楚。我做了一个主题变量,根据页面主题切换字体色和边框色,两套颜色配置独立维护。

移动端场景的核心难点不是颜色,而是交互尺寸。手机上手指点击的触发区域比鼠标大很多,地图区域过小时很难点准。短时间内的强需求是看统计数字,而不是精细交互,所以我在移动端弱化了点击下钻,只保留了 tooltip 查看数量的能力,把详情跳转放到下方列表卡片里。

无论哪一端,都要留出地图容器的安全边距,避免标签文字被容器裁切。地图容器宽度较窄时,可以适当关闭部分区域标识,只保留大区标识,等到用户缩放或者点击再补全。

5. 效果验证、常见问题与我的体会

5.1 我怎么判断这个地图算做好了

功能实现后,我给自己列了几个验收标准,分享出来供参考。

第一个标准是“三秒读数”。打开页面后,一个不了解背景的用户,能不能在三秒内说出项目最多的区域是哪一个,大概是多少个。达不到这条,说明数量标识的视觉层级还没做好。

第二个标准是“无歧义的 hover 反馈”。鼠标扫过地图时,tooltip 里展示的信息是不是完整,是不是不会闪现空白,区域名称与数字是否对应清晰。

第三个标准是“下钻不出错”。点击任意一级区域,下一级数据的数量和地图都能及时更新,且返回上一级不会丢状态。

验收没问题后,还需要做一次简单的跨屏测试,分别在电脑大屏、普通笔记本、平板和手机上看一遍,确认字体大小、标签重叠和地图缩放都能接受。

5.2 现场常见问题速查表

做项目数量标识期间,最容易碰见的问题其实是这几种,我整理了一个速查表:

问题现象可能原因处理方式
地图颜色不变聚合数据的 name 与地图边界 name 不一致建立名称映射表,统一用区域编码关联
区域中心没有数字label.show 没开,或 value 字段为空检查 series.label 配置与 data 结构
气泡位置偏移区域中心坐标不准确或缺失在 GeoJSON 中计算中心点,人工修正漂移点
数据太多导致卡顿全部项目点一次性全量渲染首屏只渲染聚合值,下钻再拉明细
小区域文字重叠相邻区域面积小,标签互相覆盖分级显示,权重低的区域改为 tooltip 查看
点击地图无响应click 事件绑定在错层元素上确认事件绑定在图表实例上,而不是容器上
地图在移动端被裁切容器高度不足或 roam 边界限制调整容器尺寸,关闭部分非必要标签

这份表格不是凭空列出来的,每一条我都实际碰到过或者帮同事排查过。其中 name 匹配和全量渲染这两个问题,是日常开发里出现概率最高的坑。

5.3 上线后我用下来的一些延伸想法

数量标识做完后,我再看这类地图,思路发生了一些变化。

你会发现,真正影响地图好不好用的,往往不是技术库本身多强,而是数据口径和地理信息对的准不准。一个数字错位,比一张图渲染慢更容易让用户失去信任。所以在这个功能里,我花在数据清洗和名称归一化上的时间,其实比写渲染代码还要多。

另外一点是,地图上的数量标识不是一个孤立组件。它和筛选条件、时间维度、项目状态都有关联。比如用户把状态切到“已结束”,地图上的数字要跟着变;用户选择只看某个季度,图上的数量也要重新聚合。开发时最好把“筛选条件 + 地图数据”拆成一个可复用的状态组合,不然每次改筛选条件都可能要单独写一套地图更新逻辑。

最后给一个小建议:如果你做的也是项目分布地图,第一版不要追求太复杂的交互。先保证数量标识准确、直观、不遮挡,再逐步叠加下钻、筛选和联动。地图可视化里,“准确”永远比“绚丽”值钱。

RELATED READING

延伸阅读

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