十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

客流预测大屏前端实战:WebSocket实时推送与ECharts可视化

客流预测大屏前端实战:WebSocket实时推送与ECharts可视化 简介面向高校计算机相关专业学生与前端开发初学者这是一份毕业设计“基于深度学习的轨道交通客流实时分析预测系统”的前端工程资源主要解决客流数据可视化、预测结果展示与交互操作落地等问题。压缩包共83个文件以tsx、ts、css、json为主涵盖React组件、TypeScript类型定义、页面样式、项目配置及Redux状态管理相关代码整体约100KB目录结构清晰便于按模块检索学习。目前已有169人学习下载适合用于毕设参考或前端工程化实践。通过学习该前端工程读者可掌握组件化开发思路、状态管理技巧、前后端数据交互代理配置以及实时数据展示的实现方式结合后端的深度学习模型能够理解轨道交通客流预测系统的完整协作流程节省从零搭建项目的时间是一份实用的前端工程样例。1. 轨道交通客流实时分析预测系统的第二版前端交付的是实时二字做完第一版能看的页面后第二版前端的难点才露出来深度学习模型每分钟推一次客流预测WebSocket 每 5 秒塞来一批站点进站出站数据图表不能卡预测线和真实线要一眼分清断线重连还不能丢数据。这套系统的前端本质是预测结果可视化终端模型吃 AFC 刷卡聚合出的分钟级客流吐出未来 15 到 30 分钟的进站出站预测和置信区间。这篇博文按数据模型—实时通道—图表渲染三层展开用 Vue3 TypeScript ECharts 给出可复现代码覆盖 WebSocket 重连、断线补数、置信区间叠加、帧率体检以及一套不依赖真实深度学习模型的 Mock 验证方案。2. 客流预测前端的数据模型实时、预测、历史三份数据分开存2.1 实时值、预测值、历史值为什么不能混在一个数组里第一版常见的写法是把后端返回的所有点塞进一个数组前端判断带 predicted 字段的就是预测画图时再 filter。短时间能跑一旦系统动起来就出问题预测值每个预测周期5 分钟或 15 分钟整体替换一次而真实值是一个一个追加进来的两者时间轴天然错位断线补数时历史数据要插到数组中间而不是尾部置信区间数组和预测数组长度相同但语义完全不同。三份数据生命周期不一样混存等于把并发时序问题全抛给渲染层。正确的做法是分开维护三个数据域实时数据域当前站点最近 N 个分钟点的真实客流追加写入超出窗口丢弃。预测数据域最近一次深度学习模型推理结果整体替换包含基准时间、步长、考核序列和置信区间。历史数据域页面初始化或断线补齐时从 REST 接口拉取只用于回填不参与增量追加。分开之后渲染层各取所需时序图同时订阅实时域和预测域热力图只订阅预测域回填逻辑只碰历史域。这套数据模型对后端接口改动也不敏感——后端从返回全部数据改成推送增量时前端只需要改实时域的写入函数。2.2 用 TypeScript 定义站点客流与预测结果类型类型定义是数据模型的第一步。客流预测系统的核心对象就两个分钟级客流点和一次完整的模型预测输出。// src/types/flow.ts /** 单个站点在某一个分钟刻度上的真实客流 */ export interface StationFlowPoint { stationId: string; /** 分钟级时间戳单位毫秒必须是分钟对齐后的值 */ timestamp: number; /** 该分钟内进站人数 */ inflow: number; /** 该分钟内出站人数 */ outflow: number; /** 数据来源realtime 为推送增量history 为断线补数 */ source: realtime | history; } /** 深度学习模型对单个站点的一次完整预测输出 */ export interface FlowPrediction { stationId: string; /** 预测基准时间模型最后一条输入数据的时刻 */ baseTime: number; /** 预测步长单位分钟通常为 5 或 15 */ interval: number; /** 预测步数 */ horizon: number; /** 进站预测序列长度为 horizon按时间升序 */ predictedInflow: number[]; /** 出站预测序列 */ predictedOutflow: number[]; /** 置信区间下界序列索引与 predictedInflow 对齐 */ confidenceLower: number[]; /** 置信区间上界序列 */ confidenceUpper: number[]; /** 模型近期平均绝对百分比误差0-100 */ mape: number; /** 模型版本号用于和训练侧对账 */ modelVersion: string; }这段类型里有三个关键约束。confidenceLower 和 confidenceUpper 必须与 predictedInflow 索引对齐前端不需要再推算某个区间对应哪个预测步后端拼装响应时保证这一点。mape 单独放在预测结果里而不是写死成常量因为同一模型在夜间和平峰时段的误差差异很大图例上显示当前 MAPE比显示训练集 MAPE更有说服力。modelVersion 用于在页面上展示当前预测由哪个模型版本产出排查问题和答辩对账时直接看页面即可。2.3 用 Pinia 组织站点状态环形缓冲与时钟偏移数据域确定之后需要一个全局 store 承接推送和渲染之间的状态。客流畅系统通常要同时展示十几条线路的断面每个站点的数据在多张图表间共享用 Pinia 统一管理更合适。// src/stores/flow.ts import { defineStore } from pinia; import type { StationFlowPoint, FlowPrediction } from /types/flow; interface StationState { /** 环形缓冲只保留最近 60 个分钟点控制内存和渲染量 */ points: StationFlowPoint[]; prediction: FlowPrediction | null; lastUpdateAt: number; } export const useFlowStore defineStore(flow, { state: () ({ stations: {} as Recordstring, StationState, /** 服务端与浏览器本地时钟的毫秒差用于对齐预测时间轴 */ serverTimeOffset: 0, }), actions: { registerStation(stationId: string) { if (!this.stations[stationId]) { this.stations[stationId] { points: [], prediction: null, lastUpdateAt: 0 }; } }, appendPoint(stationId: string, point: StationFlowPoint) { const s this.stations[stationId]; if (!s) return; s.points.push(point); if (s.points.length 60) s.points.shift(); // 超出窗口丢最老的点 s.lastUpdateAt Date.now(); }, setPrediction(stationId: string, prediction: FlowPrediction) { const s this.stations[stationId]; if (s) s.prediction prediction; }, alignTime(serverTime: number) { this.serverTimeOffset serverTime - Date.now(); }, }, });环形缓冲用 shift() 实现数组长度只有 60 时复制开销可以忽略没必要引入真正的高性能环形队列。窗口长度 60 对应最近一小时的分钟级数据如果演示时要更长的趋势线把 60 改成 120 即可但热力图和数据下钻的索引逻辑要跟着改。serverTimeOffset 这个字段最容易忽略浏览器时钟可能和服务器差几分钟预测基准时间 baseTime 是服务器写的直接拿本地时间去比较会算错预测是否已过期所有跨端时间比较必须先加上这个偏移量。store 只做数据层不负责渲染。WebSocket 收到消息后调用 appendPoint 和 setPrediction图表组件通过 storeToRefs 订阅站点状态推送速度和渲染速度就此解耦。3. 前端接入深度学习预测服务的实时通道REST 封装与 WebSocket 重连3.1 REST 接口封装历史客流拉取与断线补数虽然实时数据走 WebSocket但两类请求仍适合用 REST一是页面初始化时一次性拉取最近 N 小时的分钟级历史客流填满图表背景二是断线重连后用 REST 显式补齐断线时段的数据。预测接口也可以在需要时由前端主动触发但真实部署里模型推理通常由后端定时跑前端订阅结果即可前端主动触发只用在调试场景。// src/api/flow.ts import axios from axios; import { useFlowStore } from /stores/flow; import type { StationFlowPoint, FlowPrediction } from /types/flow; /** 历史接口和预测接口分开建实例超时策略不同 */ const historyHttp axios.create({ baseURL: /api, timeout: 8000 }); const predictHttp axios.create({ baseURL: /api, timeout: 15000 }); export interface HistoryQuery { stationId: string; startTime: number; endTime: number; /** 聚合粒度单位分钟后端只认 5/15/30 三档 */ granularity: 5 | 15 | 30; } export async function fetchHistory(query: HistoryQuery) { const { data } await historyHttp.get(/flow/history, { params: query }); if (data.code ! 0) throw new Error(历史数据接口异常: ${data.message}); return data.data as StationFlowPoint[]; // 后端按时间升序返回 } export async function fetchPrediction(stationId: string) { const { data } await predictHttp.get(/prediction/station/${stationId}); if (data.code ! 0) throw new Error(预测接口异常: ${data.message}); return data.data as FlowPrediction; } /** 断线补数把本地最后一条数据到当前时间之间的缺口补齐 */ export async function fillGap(stationId: string, lastTimestamp: number) { const end Date.now(); const points await fetchHistory({ stationId, startTime: lastTimestamp 60_000, endTime: end, granularity: 5, }); points.forEach((p) useFlowStore().appendPoint(stationId, p)); }historyHttp 超时设 8 秒predictHttp 设 15 秒模型推理在 GPU 上通常几百毫秒但遇到排队推理、批处理没凑满的情况会拖到秒级超时设太短会把慢误判成挂。granularity 刻意限成 5/15/30 三个值后端聚合逻辑只认这三档前端传别的值会拿到空数组这种约定写进类型比写进注释更能拦住误用。fillGap 的 startTime 从 lastTimestamp 往后跳一分钟避免把最后一条已收到的数据重复拉回来产生重复点。注意baseURL 用相对路径 /api 时Vite 开发代理要同时转发 /api 和 /ws 两个前缀。REST 和 WebSocket 通常在同一网关端口但路径前缀不同漏配任意一个联调环境都会不一致。3.2 WebSocket 订阅实时客流消息协议与增量写入实时通道用 WebSocket 而不是轮询原因直接客流增量和预测推送都要低延迟轮询会让实时变成最多延迟一个轮询周期无数据时段还会浪费大量 HTTP 请求。连接建立后前端先发订阅消息告诉服务端要哪些站点和推送粒度服务端回快照之后按分钟推增量。消息类型 type方向说明subscribe客户端 → 服务端携带站点列表与推送粒度flow.snapshot服务端 → 客户端连接建立后一次性下发的最近 N 条客流flow.realtime服务端 → 客户端单站点的分钟级增量客流flow.predict服务端 → 客户端模型新一次推理结果整体替换旧预测ping / pong双向心跳探测死链接消息类型和 REST 路径保持同一命名习惯排查问题时只要沿着类型名就能串起整条链路。下面是客户端封装的核心部分。// src/services/realtime.ts import { useFlowStore } from /stores/flow; import type { StationFlowPoint, FlowPrediction } from /types/flow; type ServerMessage | { type: flow.snapshot; data: { stationId: string; points: StationFlowPoint[] } } | { type: flow.realtime; data: StationFlowPoint } | { type: flow.predict; data: FlowPrediction }; export class RealtimeFlowClient { private ws: WebSocket | null null; private stations: string[] []; private reconnectAttempt 0; private pingTimer: number | undefined; private store useFlowStore(); connect(stations: string[]) { this.stations stations; const proto location.protocol https: ? wss : ws; this.ws new WebSocket(${proto}://${location.host}/ws/flow); this.ws.onopen () this.onOpen(); this.ws.onmessage (ev) this.onMessage(ev.data); this.ws.onclose () this.onClose(); } private onOpen() { this.reconnectAttempt 0; this.ws?.send(JSON.stringify({ type: subscribe, stations: this.stations, granularity: 5 })); clearInterval(this.pingTimer); /** 30 秒一次心跳小于多数中间设备的空闲回收时间 */ this.pingTimer window.setInterval(() this.ws?.send(JSON.stringify({ type: ping })), 30_000); } private onMessage(raw: string) { const msg: ServerMessage JSON.parse(raw); if (msg.type flow.snapshot) { const points msg.data.points; points.forEach((p) this.store.appendPoint(msg.data.stationId, p)); const last points[points.length - 1]; if (last) this.store.alignTime(last.timestamp); // 快照同时校准时钟差 } else if (msg.type flow.realtime) { this.store.appendPoint(msg.data.stationId, msg.data); } else if (msg.type flow.predict) { this.store.setPrediction(msg.data.stationId, msg.data); } } private onClose() { clearInterval(this.pingTimer); const maxAttempt 8; if (this.reconnectAttempt maxAttempt) { this.fallbackToPolling(); // 超过 8 次改用 REST 轮询兜底避免白屏 return; } const delay Math.min(1_000 * 2 ** this.reconnectAttempt, 15_000); this.reconnectAttempt 1; setTimeout(() this.connect(this.stations), delay); } }心跳 30 秒一次大多数云环境下TCP 空闲连接会被中间设备在 60 到 90 秒内回收心跳间隔必须小于这个值否则连接悄悄断了前端却不知道。重连用指数退避从 1 秒、2 秒翻倍到 15 秒封顶避免服务端重启时几百个页面同时重连造成雪崩。8 次重连仍失败后降级成 REST 轮询这条逻辑在答辩时大概率被问WebSocket 挂了怎么办fallbackToPolling 就是回答。3.3 断线补数与预测结果被真实值覆盖断线期间服务端通常不会为单个客户端保留消息队列重连后收到的 flow.snapshot 是否带上断线期间的数据取决于后端实现。稳妥的前端做法是不依赖快照补全而是主动补数重连拿到快照后对比本地最后一个点的时间戳如果断层超过一个推送周期就调用 fillGap。这样可以保证在任意后端实现下页面数据都不会出现空洞。预测结果还有一个时序坑模型推理需要时间flow.predict 到达前端时往往已经有几条更新的真实客流先到了。图表上同一时刻同时存在真实值和预测值被真实值覆盖的预测步不应该再画。统一用时间戳判断覆盖而不是用索引// src/utils/merge.ts import type { FlowPrediction, StationFlowPoint } from /types/flow; /** 剔除已被真实客流覆盖的预测步返回带置信区间的有效预测序列 */ export function effectivePrediction( prediction: FlowPrediction, realtimePoints: StationFlowPoint[], ) { const covered new Set(realtimePoints.map((p) p.timestamp)); const stepMs prediction.interval * 60_000; const out []; for (let i 0; i prediction.horizon; i) { const t prediction.baseTime (i 1) * stepMs; if (!covered.has(t)) { out.push({ time: t, inflow: prediction.predictedInflow[i], outflow: prediction.predictedOutflow[i], lower: prediction.confidenceLower[i], upper: prediction.confidenceUpper[i], }); } } return out; }核心是用时间戳集合判断覆盖真实值来自推送到达顺序不一定严格升序Set 判断天然免疫乱序。baseTime 加 (i 1) 倍步长表示第 i 步落在未来第 i1 个刻度索引从 0 开始这里不加 1预测曲线会整体左移一个步长画出来对不上真实曲线是这类系统里最常见的低级错误。4. ECharts 渲染客流实时预测大屏时序图、热力图与关键参数4.1 时序图叠加真实曲线、预测虚线和置信区间带主图通常是站点时序图横轴时间纵轴人次/分钟真实值画实线预测值画虚线置信区间画半透明区域。置信区间在 ECharts 里最常见的实现是两个 line 用 stack 拼成面积带下界线压 0上界线改用上下界之差。// src/components/StationFlowChart.vue —— 构建 option 的核心函数 import { useFlowStore } from /stores/flow; import { effectivePrediction } from /utils/merge; function buildOption(stationId: string) { const store useFlowStore(); const state store.stations[stationId]; const realData state.points.map((p) [p.timestamp, p.inflow]); const pred effectivePrediction(state.prediction, state.points); const bandLower pred.map((d) [d.time, d.lower]); const bandUpper pred.map((d) [d.time, d.upper - d.lower]); return { animation: false, tooltip: { trigger: axis }, legend: { data: [真实客流, 模型预测, 置信区间], top: 4 }, grid: { left: 56, right: 24, top: 44, bottom: 40 }, xAxis: { type: time }, yAxis: { type: value, name: 人次/分钟 }, series: [ { name: 真实客流, type: line, data: realData, symbol: none, lineStyle: { width: 2 } }, { name: 模型预测, type: line, data: pred.map((d) [d.time, d.inflow]), symbol: none, lineStyle: { type: dashed, width: 2 } }, { name: 置信区间, type: line, data: bandLower, stack: band, lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 }, silent: true }, { name: 置信区间上界, type: line, data: bandUpper, stack: band, lineStyle: { opacity: 0 }, areaStyle: { opacity: 0.18 }, silent: true }, ], }; }series 顺序有讲究置信区间两条线必须排在真实客流之前否则半透明面积带会把实线盖住。silent: true 给置信区间关掉鼠标事件否则 tooltip 触发时会出现一条没有对应值的空白项。animation: false 在实时模式下必开ECharts 的入场动画会在每次 setOption 更新时重放开着动画就等于让每秒一次的推送变成每秒一次闪烁。4.2 热力图与断面客流把预测铺到位置维度时序图回答某个站未来半小时怎么样热力图回答哪些站在什么时段先饱和。客流系统常用一张 x 轴为时间桶、y 轴为站点的热力图颜色深浅表示该站该时段的预测进站量。数据组织方式决定了这张图能不能一次画对。// src/components/StationHeatmap.vue —— 热力图数据构建 const timeBuckets: string[] [7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00]; const stations: string[] [...]; // 按线路走向排序的站点名 // 数据格式[x 索引, y 索引, 数值]x 是时间桶序号y 是站点序号 const heatData stations.flatMap((stationId, yIdx) timeBuckets.map((_, xIdx) [xIdx, yIdx, predictedInflowOf(stationId, xIdx)]), ); const option { tooltip: { position: top }, grid: { left: 120, top: 20, bottom: 60, right: 40 }, xAxis: { type: category, data: timeBuckets, splitArea: { show: true } }, yAxis: { type: category, data: stations, splitArea: { show: true } }, visualMap: { min: 0, max: 1200, // 由历史最大值推导避免每轮推送后色标跳变 calculable: true, orient: horizontal, left: center, bottom: 0, }, series: [{ type: heatmap, data: heatData, progressive: 2000, label: { show: false } }], };visualMap 的 max 不要用当前帧数据的最大值。客流有早晚高峰8 点整的预测值可能是 7 点的两倍max 跟随数据跳会让人以为颜色没变但阈值变了产生错误读数。正确做法是用过去一周同时段的历史最大值做基准前端启动时从 REST 接口拉一次存下来。progressive: 2000 是 ECharts 大数据分块渲染阈值30 个站点、9 个时间桶时只有 270 个数据点不设也流畅接入全路网 300 站级别时这个参数能避免一次性绘制卡顿。4.3 setOption 的更新节奏lazyUpdate、notMerge 与数据窗口实时图表最常踩的坑是每收到一条消息就 setOption 一次。WebSocket 5 秒推送一轮、20 个站点时每轮 20 次 setOption 全部触发布局与绘制主线程直接被占满大屏上其他交互全部卡死。正确做法是渲染层自己决定更新频率消息层只负责写 store。// 图表组件内定时器驱动重绘而不是消息驱动 const updateTimer window.setInterval(() { if (!chart) return; chart.setOption(buildOption(currentStationId()), { notMerge: false, // 保留图表实例的缩放、图例开关状态 lazyUpdate: true, // 同一渲染帧内的多次 setOption 合并为一次 }); }, 1000); onBeforeUnmount(() { window.clearInterval(updateTimer); chart?.dispose(); // 组件卸载必须释放否则 canvas 实例持续累积 });重绘周期 1 秒和分钟级数据的时间精度完全匹配。notMerge 保持 false 时新旧 option 做增量合并用户做过的 dataZoom 缩放、图例开关会保留一旦改成 true每次重绘都重置视图大屏上反复闪烁。lazyUpdate 把同一帧内的多次 setOption 合并成一次绘制是压住 CPU 的关键参数。图表销毁同样重要SPA 路由切换后 canvas 不释放内存会随页面停留时间线性上涨。参数取值作用animationfalse关闭入场动画避免每次推送重放lazyUpdatetrue同一帧多次 setOption 合并为一次绘制notMergefalse保留缩放和图例交互状态progressive2000大数据量时分块渲染避免一次性卡顿一个常见误用是拿 appendData 做实时追加。appendData 适合大数据流模式但它要求维度固定、不可回退客流预测这种预测步被真实值覆盖后要摘除的场景需要整段重算序列用 setOption 覆盖更简单可靠。5. 交付前验证Mock 模型跑通全链路与前端性能体检5.1 一个能装成深度学习服务的 Mock答辩现场没有真实 AFC 数据流前端要自证实时和预测两条链路都通最好的办法是让 Mock 服务按真实模型的行为返回有延迟、有波动、有置信区间baseTime 取最新真实点的时间戳。REST 预测接口用 vite-plugin-mock 拦截推流通道用 Node 的 ws 库单独起进程前端通过环境变量 WS_URL 切换联调时指向真实网关代码零改动。// scripts/gen-prediction.js —— 生成带客流规律和噪声的预测序列 function genPrediction(baseTime) { const horizon 12; const stepMs 5 * 60 * 1000; const inflow Array.from({ length: horizon }, (_, i) Math.round(300 40 * Math.sin(i / 3) (Math.random() - 0.5) * 30), ); return inflow.map((v, i) ({ time: baseTime (i 1) * stepMs, inflow: v, lower: Math.round(v * 0.82), upper: Math.round(v * 1.10), })); }正弦函数模拟早高峰的周期性随机项模拟客流波动这样的预测序列画出来才像真的模型输出。最容易写错的地方是 baseTime必须取最新真实点的时间戳如果取本地时间前端 effectivePrediction 会因为时间轴对不上把整条预测当成过期数据剔除图上只剩真实线。5.2 三项体检帧率、最慢帧与消息积压实时大屏的验收不能用看着不卡糊弄。用 requestAnimationFrame 统计每秒帧率记录每帧绘制耗时跑 10 分钟观察内存曲线是否收敛同时给 WebSocket 消息打计数日志确认消息到达速率与图表重绘速率之间没有积压。帧率低于 30、最慢帧超过 100 毫秒、内存持续增长三项里任何一项出问题都要回到数据窗口长度和 lazyUpdate 参数上重查。第二版相对第一版的核心验收点就是这三项指标在长时间推送下仍然稳定。5.3 把误差指标画进图例最后一个小技巧模型的 MAPE 不要放在页面角落的文案区直接进图例。用 legend 的 formatter 拿到系列名把预测系列渲染成模型预测MAPE 8.6%误差和曲线的对应关系一目了然。置信区间永远放在 series 最底层真实客流实线放最上层配色上预测用浅色虚线、真实用深色实线这是前端让调度人员敢信预测的最后一道界面保障。本文还有配套的精品资源点击获取
返回列表