ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

大屏数据可视化实战:疫情监控系统2的开发全流程解析

大屏数据可视化实战:疫情监控系统2的开发全流程解析

大屏数据可视化这个方向,听着确实高大上,但真正做起来之后你会发现,它就是一场“体力活+细心活”的结合。去年年中我接了一个“疫情监控系统”的二期改造任务,也就是标题里这个“疫情监控系统2”,核心目标是把第一版那种静态看板彻底推翻,做成一个真正跑在Web端、数据能够动态刷新的可视化大屏。这个项目上线后,我回头整理了一下整个研发过程,发现里面值得记录的东西特别多,从技术选型、布局设计到数据轮询、大屏适配,再到上线后踩过的那些坑,每一个环节都能单独写一篇复盘。

这篇内容更适合两类人看:一类是刚接触可视化大屏项目的开发者,可以把它当成一份完整的实操参考;另一类是已经做过大屏但遇到性能、适配或者数据刷新问题的人,可以直接跳到第4章查问题。我会把整个项目的拆解思路、核心代码、参数取舍和避坑经验全部写出来,尽量做到你拿过去就能用。

1. 项目整体设计思路与技术选型

大屏可视化项目跟普通后台管理系统完全不同。后台系统讲究功能密度,用户可以靠鼠标点击、键盘输入来完成操作;而大屏的核心目标是“让站在几米之外的人一眼看懂当前发生了什么”。所以第一件事不是去找图表库,而是先搞清楚三个问题:给谁看、看什么、呈现到什么程度。

1.1 需求定位与功能拆解

“给谁看”决定了信息的颗粒度。这个疫情监控系统的使用者是应急指挥中心和防疫工作专班,不是普通市民。值班人员需要在一面大屏上同时掌握这几个维度的信息:

  • 当前疫情总体态势:累计确诊、现有确诊、治愈、死亡等核心指标;
  • 区域风险分布:哪个区县风险等级高、新增病例集中在哪;
  • 时间变化趋势:最近14天的新增病例曲线是抬头还是回落;
  • 资源保障情况:核酸采样点数量、隔离床位使用率、物资储备状态。

基于这些诉求,我把大屏的信息架构拆成了四个区域:顶部是全局标题和统计时间,中间主视觉区域放风险地图,左右两翼放核心指标卡、趋势图和排名列表,底部加一条实时滚动公告栏,用来播报最新通知和防疫提示。

注意这里有个关键点:大屏不是报表,数据维度不能贪多。每个区域只承担一个职责,指标卡只显示数字,趋势图只画曲线,地图只展示空间分布。如果试图在一个图表里塞下所有信息,远观时反而什么都看不清。

1.2 技术栈选型与方案对比

技术上我没有选择特别花哨的方案,核心是“稳定、快、省心”。最终定下来的技术栈是 Vue 3 + ECharts 5 + WebSocket + 轻量级 Node.js 后端服务。

这里我对比过几个方案,把当时考虑过的技术选型整理成了表格:

方案优势劣势适用场景
Vue 3 + ECharts 5生态成熟、文档全、图表类型丰富、上手快大数据量渲染时需手动优化绝大多数中后台大屏场景
React + AntV G2图形语法灵活、适合定制化图表学习成本略高、社区方案相对分散对图表形态有强定制诉求的团队
原生 JS + Leaflet地图渲染性能极好图表体系需要自己造轮子以GIS为主、图表为辅的场景
开源可视化平台(DataV等)开箱即用、组件丰富定制能力受限、商用授权成本高非技术团队快速搭建原型

考虑到开发周期和后续维护成本,Vue + ECharts 的组合最稳妥。ECharts 本身就是百度开源的老牌项目,疫情监控地图、趋势图、散点图它都能直接支持,社区资料也很丰富,遇到问题一搜就能解决。

数据通信方面,我采用“轮询为主、WebSocket为辅”的混合模式。为什么不做纯 WebSocket?因为疫情数据的更新频率通常不高,大部分接口是每30分钟甚至每小时汇总一次,轮询的成本并不可怕。但为了让大屏在数据变化时能立刻响应,我给实时性要求最高的“新增病例数”单独拉了 WebSocket 通道,其他指标走普通 HTTP 接口轮询。后面第3章会详细讲这个取舍的逻辑。

1.3 数据链路与接口设计

大屏的数据链路看似简单,其实就是“前端-后端-数据库”三个节点,但真正做起来容易在这里翻车。第一版系统最大的问题就是前端直接查明细表,几千条记录一次性丢给浏览器,ECharts 直接卡死。

这次我重新梳理了数据链路:

数据源(上报系统/数据库) ↓ 服务端聚合处理(按区域、按日期汇总) ↓ 轻量级API(只输出大屏需要的结果) ↓ 前端WebSocket/轮询拉取 ↓ ECharts渲染

服务端聚合这一步是整个架构的关键。大屏前端不应该拿到明细数据,而是应该拿到已经算好的指标结果。比如地图组件需要的是“每个区县现有确诊数”,那后端给到的数据就应该是:

[ { "area": "A区", "confirmed": 12, "riskLevel": "high" }, { "area": "B区", "confirmed": 5, "riskLevel": "medium" }, { "area": "C区", "confirmed": 0, "riskLevel": "low" } ]

前端只负责把结构化的数据映射到地图上,不做任何计算。这样既减轻了浏览器的负担,也避免了不同图表各自计算导致的数据不一致。

我踩过的一个明显的坑是:第一版系统里趋势图的数据来自一个接口,排名列表的数据来自另一个接口,两边对“新增病例”的统计口径不一致,导致大屏上半张图显示上升、下半张表却在下降,非常尴尬。所以二期项目我把所有统计口径都统一收敛到服务端,由服务端保证大屏上所有数字来自同一份数据源,前端只信接口,不自己做二次聚合。

2. 大屏布局、地图与可视化组件实现

技术方案定好之后,接下来就是最耗时的工作:把大屏从视觉稿变成真实可交互的Web页面。这个阶段要处理三件核心的事:页面布局的拆分、地图可视化的搭建、各类图表组件的细节调优。

2.1 布局规划与组件拆分

大屏的分辨率基准我定的是 1920x1080,这是目前标准拼接屏和LED大屏最通用的规格。在这个画布下,我用了典型的“上-中-下”三段式结构,配合左右两侧的纵向面板:

  • 顶部区域(高度约100px):展示系统标题、当前时间、数据更新时间;
  • 中部主区域(宽度约60%):全屏风险地图,这是视觉重心;
  • 左侧面板(宽度约20%):疫情核心指标卡、风险区县排行;
  • 右侧面板(宽度约20%):新增病例趋势图、资源保障进度条;
  • 底部区域(高度约60px):滚动公告栏。

这种三段式布局的好处是信息层级非常清楚:中间地图负责空间感,两侧面板负责数据密度,顶部和底部负责时间和文本信息。用户站在远处,视线首先会落在地图上,然后自然流向两侧的数据面板。

组件拆分上,我把每个可视区域都单独封装成了Vue组件,比如HeaderBar.vue、RiskMap.vue、TrendChart.vue、MetricCard.vue。组件独立之后有几个直接的好处:

  • 每个组件自己管理自己的数据和渲染逻辑,互不干扰;
  • 某个图表出问题时,只需要定位到对应组件,不需要翻遍整个页面代码;
  • 后期如果想把某个模块单独拆出去给手机端用,只需要复用组件即可。

需要注意的一点是,组件的data和timer生命周期一定要管好。大屏页面是长时间挂机的,轮询定时器如果不清除,组件切换或者热更新时会在后台越积越多,最终把浏览器拖垮。这些问题我会在第3章和第4章重点展开。

2.2 地图可视化实现细节

地图是整个大屏的核心,也是实现起来最容易出问题的地方。疫情数据天然带有空间属性,指挥中心最关心的是什么?是“疫情发生在哪、扩散到什么范围”。所以风险地图默认展示区域划分,用不同颜色标注不同风险等级,并叠加散点图展示具体风险点。

ECharts 的map类型地图有两种做法:一种是用 ECharts 内置的地图数据,另一种是引入外部 GeoJSON。内置地图用起来简单,但更新慢、边界精度一般;GeoJSON 可以非常精确地展示每个区县边界。我做的是市-区县两级下钻,所以选择了 GeoJSON 方案。

GeoJSON 的加载有几项注意事项,我这里直接列给后来的人参考:

  • 文件格式优先用精简过的 TopoJSON 或压缩过的 GeoJSON,不要直接丢一个几十MB的原始文件到前端;
  • 可以通过地图数据网站的 json 精简工具去除多余的坐标精度,经纬度保留4位小数足够用了;
  • ECharts 通过echarts.registerMap('region', geoJson)注册地图,注册之后才能用map: 'region'这个配置项。

地图的核心配置我写了一个示例,这是当时真实在用的简化版本:

option = { tooltip: { trigger: 'item', formatter: (params) => { if (params.data) { return `${params.name}<br/>现有确诊:${params.data.confirmed}<br/>风险等级:${params.data.riskLevel === 'high' ? '高风险' : '中风险'}`; } return params.name; } }, visualMap: { type: 'piecewise', pieces: [ { min: 1, max: 5, label: '低风险', color: '#ffd666' }, { min: 6, max: 20, label: '中风险', color: '#ff9c6e' }, { min: 21, label: '高风险', color: '#e64545' } ], textStyle: { color: '#fff' } }, series: [{ type: 'map', map: 'region', roam: true, label: { show: false }, data: mapData }] };

这里的visualMap是最关键的配置项,它决定了不同风险等级的区域会显示什么颜色。我遇到过一个问题:地图数据更新后,颜色却不变。排查了半天发现是visualMap的pieces区间和实际数据范围不匹配,数据里出现了21以上的值,但pieces只定义到20,超出部分就没有颜色规则。所以在设置区间时,上限一定不要封死,要给极端情况留一个兜底区间。

散点图叠加也用 ECharts,直接在series里再增加一个scatter或effectScatter系列。这里注意一下geo坐标系和map系列的匹配问题,如果是用map系列来画地图,叠加散点时建议通过geoIndex关联,避免散点位置错乱。

2.3 指标卡、趋势图与排行列表的实现技巧

地图之外,大屏两边还有很多“小而关键”的组件。这些组件看起来简单,实际写代码时有不少细节。

指标卡的实现是最简单的,但它承担了大屏最重要的信息传达任务。设计上要突出数字本身,所以字号要大、数字要有动态滚动效果。数字滚动的实现我没有用额外的动画库,而是直接监听数据变化,用requestAnimationFrame做数字过渡:

function animateNumber(el, from, to, duration = 800) { const start = performance.now(); function update(now) { const progress = Math.min((now - start) / duration, 1); const current = Math.floor(from + (to - from) * progress); el.textContent = current.toLocaleString(); if (progress < 1) requestAnimationFrame(update); } requestAnimationFrame(update); }

这个实现方式有几个好处:数字变化时视觉上更有冲击力,而且不会改变DOM结构,避免了表格重排等性能问题。

趋势图我用的是折线图加面积填充。趋势图最核心的诉求是让“上升”还是“回落”一目了然,所以不需要过多装饰,只要清晰呈现14天内新增病例的变化即可。需要注意一个细节:折线图的时间轴X轴最好保持固定区间,例如最近14天。不要把没数据的日期直接丢掉,否则折线在视觉上会前移,给值班人员造成错误的趋势判断。

排行列表用了一个简单带有滚动效果的表格,优先展示“今日新增病例最多的5个区县”,红色字体高亮第一名。这里有个小技巧:排行数据如果长期不变,列表不要一直滚动,会干扰阅读。我做了个判断,仅当数据更新或前五名排序变化时才触发滚动动画,数据保持稳定时列表就静止显示。

3. 实时数据刷新机制与大屏适配方案

大屏和普通网页另一个不同点是:它可能连续运行几个星期不需要人去碰它。所以“实时刷新”和“适配稳定性”这两个问题如果没处理好,上线后就是灾难。

3.1 轮询与WebSocket:实时刷新方案的取舍

实时刷新有两种主流玩法:轮询和服务端推送。

轮询就是前端按固定时间间隔去拉取一次接口,实现简单,兼容性最好。疫情监控系统的数据汇总节奏通常是半小时级,所以30秒轮询一次已经是非常保守的频率了,既能保证数据延迟在一分钟内,也能极大降低后端压力。

WebSocket 则是由服务端主动推送数据,延迟更低,但对服务端要求更高,连接管理、断线重连、推送频率控制都要自己处理。

我当时做了一个混合设计,具体分工是这样的:

  • 地图数据和指标卡数据:HTTP轮询,间隔30秒;
  • 新增病例数、公告信息:WebSocket实时推送;
  • 趋势图和排行列表:HTTP轮询,间隔60秒。

为什么不全部走 WebSocket?因为大屏的模块对实时性要求差异很大。趋势图是14天的数据,60秒刷新一次和秒级刷新一次,视觉上没有任何区别;指标卡是新发布的数据,30秒内的延迟也完全可以接受。只有“新增病例数”这种关键指标,才值得为它单独维护一条长连接。这其实是一个资源投入的权衡,不应该为了技术炫耀而放弃简单可靠的方案。

轮询的实现我封装了一个可复用的工具函数:

function startPolling(requestFn, interval, onSuccess) { let timer = null; let isRunning = false; async function poll() { if (isRunning) return; isRunning = true; try { const res = await requestFn(); onSuccess(res); } catch (e) { console.error('[polling error]', e); } finally { isRunning = false; } } poll(); timer = setInterval(poll, interval); return { stop() { if (timer) clearInterval(timer); } }; }

这里isRunning这个标志位非常重要。如果不加这个判断,当接口响应较慢时,上一次请求还没结束,下一次心跳又触发了,会造成请求堆积,最终导致数据乱序和页面卡顿。

WebSocket 断线重连也是必须处理的。值班大屏不能因为网络波动就永远停在旧数据上。我写了一个带指数退避的重连策略:断线后延迟1秒重连,不断失败则逐步拉长延迟到30秒封顶,避免服务端被大量重连请求打爆。

3.2 大屏分辨率的适配策略

大屏的适配是个老生常谈但又绕不开的问题。各种大屏硬件的物理分辨率五花八门,有1920x1080的液晶拼接屏,也有单块4K的LED巨幕。如果只按一种分辨率写死,换一块屏幕就全乱。

我采用的适配方案是整体缩放 + 自适应居中。具体做法是:以1920x1080为设计基准,页面加载时读取当前视口尺寸,计算缩放比例,用CSS3的transform: scale()对整个大屏容器进行等比缩放,保证在任何分辨率下布局都不变形。

function handleResize() { const designWidth = 1920; const designHeight = 1080; const scaleX = window.innerWidth / designWidth; const scaleY = window.innerHeight / designHeight; const scale = Math.min(scaleX, scaleY); const app = document.getElementById('screen-container'); app.style.transform = `scale(${scale})`; app.style.transformOrigin = 'center center'; } window.addEventListener('resize', handleResize); handleResize();

用Math.min(scaleX, scaleY)的目的是保证整体等比缩放,宁可上下或左右留一点黑边,也不能让画面变形拉伸。如果是真正用在拼接墙上,最好再联系硬件厂商拿到准确的物理分辨率,这样才能做到像素级对齐。

这里有一个非常容易忽略的细节:ECharts 在缩放容器中渲染时,图表本身的像素值是固定的。如果外部的transform: scale()已经放大或缩小,图表内部的字体和图标不会跟着一起缩放,就会出现“图表整体放大了,但文字和图例模糊成一团”的情况。解决办法是监听缩放比例,动态调用chart.resize(),并且在resize时对 ECharts 的文字大小做相应调整。

3.3 性能优化与长时间运行稳定性

大屏项目另一个容易翻车的地方是性能。一个页面放了十几张图表,每30秒刷新一次,连续跑24小时,如果不做性能优化,浏览器很容易内存不断上涨,最终变得卡顿甚至白屏。

我总结出三个最值得做的优化点:

第一,组件销毁时一定要销毁图表实例。Vue 3 中,组件beforeUnmount生命周期里要做chart.dispose(),否则图表实例会残留在内存中。长时间运行的大屏页面,如果经历过组件热更新或者手动刷新,就会出现多个图表实例并存的情况。我后来在页面里主动打印过实例数量,最多的时候出现过十几份重复实例,内存直接飙到800MB。

onBeforeUnmount(() => { if (chartInstance) { chartInstance.dispose(); chartInstance = null; } });

第二,数据刷新时不要setOption全量覆盖。ECharts 的setOption支持传第二个参数notMerge,如果设置为true,它会把旧图表的配置全部清空重来,非常消耗性能。大屏轮询时数据一般只更新数值,图表的配置项、坐标系、颜色映射都没变,此时应该保持notMerge为false(默认),让 ECharts 做增量更新。

第三,优化数据量的粒度和轮询频率。地图下钻到区县级别,每张地图最多几十个区域,完全没必要高频刷新。对实时性要求不高的数据,轮询间隔可以放到60秒甚至更长,给浏览器留出喘息的时间。

4. 部署上线与常见问题排查实录

这个项目上线前的部署和上线后的运维,其实比开发本身更磨人。尤其是大屏环境,经常是内网、特殊网络、甚至没有公网,各种奇怪问题都会冒出来。我把遇到过的典型问题和排查思路整理成了清单,供参考。

4.1 部署环境与基础配置

后端接口是一个轻量服务,部署在一台内网服务器上,前端构建后的静态资源直接放到 Nginx 里。基础配置大概是这样的:

server { listen 8080; server_name localhost; root /data/epidemic-screen/dist; index index.html; # 前端静态资源缓存 location /assets/ { expires 7d; add_header Cache-Control "public"; } # 后端API转发 location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 解决前端路由history模式刷新404问题 location / { try_files $uri $uri/ /index.html; } }

大屏这种长时间挂机的页面,可靠性是第一位的。静态资源要设置好缓存,index.html最好不缓存,这样前端每次发布新版本后,刷新页面就能拿到最新的入口;而assets/下面的 JS、CSS 都带上了内容哈希,可以长期缓存,减少重复拉取。

接口服务如果跟前端页面不在同一个域名下,就会涉及到跨域问题。这里我强烈建议通过 Nignx 反向代理来解决,而不是在接口代码里乱加Access-Control-Allow-Origin。代理让前后端在同一个域名下,接口地址以/api前缀区分,既解决了跨域,又隐藏了后端真实端口,安全性也更好。

4.2 常见问题与排查方法

大屏项目的问题很多都是“只在现场出现、开发环境复现不了”的,所以排查思路特别重要。我把遇到典型的四类问题整理成了速查表:

现象可能原因排查方法与处理
大屏画面被拉伸变形分辨率基准与墙不匹配,或CSS缩放失败核对物理分辨率,检查容器transform是否生效
页面跑一晚上后越来越卡图表实例没销毁、轮询请求堆积检查内存快照,beforeUnmount中dispose,轮询接口加并发锁
地图新增数据后颜色不更新visualMap区间未覆盖新增值检查数据极值,确认pieces区间下限/上限无遗漏
大屏白屏或显示陈旧数据WebSocket 断线且无重连、或接口超时看网络面板确认连接状态,确认重连机制是否执行

排查大屏问题时有个经验:优先看浏览器的网络面板和内存面板,而不是直接翻代码。网络面板能确认每个轮询请求是否正常返回,内存面板能快速发现是不是存在内存泄漏。很多时候问题并不在渲染逻辑里,而是在数据链路和生命周期管理上。

4.3 值班场景下的稳定性增强

大屏一旦上线,通常没有前端工程师在旁边盯着改代码,所以上线前要做一些“抗造”处理:

  • 后端接口挂掉时,前端要保留最近一次成功的数据并显示在页面上,同时在界面角落提示“数据更新异常”,不能直接把整屏清成空白;
  • 给页面加一个“手动刷新”按钮,放在角落,不要明显,方便现场人员临时触发数据更新;
  • 所有轮询请求需要设置超时时间,接口如果5秒内没有响应就自动跳过这次,不能无限等待。

另外,如果大屏需要通过遥控器或中控系统切换信号源,那么页面还应该接一个可视化的“退出”按钮,或者至少提供一键清屏的能力,不然晚会或者汇报结束后,大屏会一直挂着没人会关。这些都是实际带队时会被现场人员反复提出的需求,提前做掉能省掉后面很多沟通成本。

5. 项目迭代过程中沉淀的几个关键经验

项目做完之后,我回头复盘了一下,觉得有几个开发习惯和做项目的理念,是这个项目带给我最有价值的东西。单独拿出来跟大家分享一下,都是很现实的经验。

第一,大屏项目一定要先确认“屏”的真实参数再动手写代码。很多项目开发时用的是1920x1080的普通显示器,结果上线前一看是块长方形的异形拼接屏,比例完全不同,前端的所有布局都要重做。我的建议是进场前就跟硬件沟通清楚,拿到准确的显示分辨率和尺寸,宁可在需求阶段多问几句,也不要最后阶段通宵返工。

第二,数据结构和统计口径要在开发第一天就定义清楚。大屏上如果同时出现“累计确诊”和“现有确诊”,逻辑关系是什么?后端返回的字段是confirmed还是totalConfirmed?这些如果不在接口文档里统一,前端后端反复扯皮是小,上线后数据对不上才是大问题。这个系统二期我强制推行了接口契约文档,所有字段命名、含义、取值单位都在文档里规定好,后面联调阶段顺利了很多。

第三,用 Mock 数据先开发前端。大屏项目往往图表交互复杂,而后端数据还在采集清洗阶段。如果非要等接口好了再开发页面,工期一定扛不住。我在这个项目里把所有接口都先定义成 Mock 数据,前端先把页面和图表全部调通,后端好了之后直接替换请求地址。这样做最大的价值是前端可以提前预演所有边界情况,例如数据为空时怎么显示、接口异常时怎么降级,这些逻辑如果在上线前没想清楚,上线后一定会暴露。

第四,给大屏加上“数据时点”的展示。大屏上必须能看到“数据更新于什么时间”,这是整个项目里指挥中心反馈最强烈的一个需求。早期版本没有这个时间戳,值班人员看着屏幕永远是慌的,不知道当前数字是几分钟前还是几天前的。加了更新时间后,一眼就知道系统有没有在正常刷新。这个功能实现成本极低,价值却极高,越早做越好。

RELATED READING

延伸阅读

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