
做智能电网模拟做到这个阶段前端这块算是我花时间最多、也最有得聊的一部分。前面几篇把仿真内核、潮流计算、数据源对接都聊完了今天这篇重点放在前端架构和渲染实现上电网拓扑图在浏览器里怎么画出来实时数据怎么刷上去缩放平移和选中交互怎么做以及我在这个课设里踩过的几个真坑。这篇文章适合正在做电网仿真、数字孪生、组态软件类课设或者初版Demo的同学参考。你要解决的核心问题就一个后端给你一堆电网模型数据母线、线路、变压器、量测值前端怎么自然、流畅、不闪不卡地把它变成一块带交互的监视屏。1. 项目背景与前端需求拆解1.1 一套电网模拟界面到底要展示什么先明确“智能电网模拟”的界面输出是什么。我们用的是IEEE 30节点系统做仿真底子后端跑潮流计算前端要展示的东西主要有四类。第一类是电网拓扑结构。30个母线节点、几十条线路、变压器、发电机、负荷这些设备必须在画布上按拓扑关系摆放出来让评审一眼看出这套系统“有电网的样子”。拓扑图不是真的要按照物理经纬度画但要保证视觉布局清晰线路不能乱交叉。第二类是实时量测数据。节点电压、相角、线路有功无功潮流、负荷功率这些数据由仿真内核周期性计算产生前端要么轮询后端接口要么走实时推送通道拿到以后要更新到界面。第三类是运行状态与告警。哪些节点电压越限、哪些线路过载状态量要用颜色、闪烁、告警列表这些形式表达出来这是“模拟系统”和“静态画图”最大的区别。第四类是分析曲线。负荷曲线、发电出力曲线、电压曲线这些图表用来展示仿真过程的变化趋势。在这个前提下前端架构的目标不是“把页面做到多炫”而是把上面四类内容稳定、清晰、可维护地组织起来。1.2 课设前端的现实约束课程设计不是生产项目。时间一共就几周前端通常还是一个人写。所以我给自己定的开发约束是三条技术栈必须自己熟练不能为了追新引入大量学习成本渲染核心尽量可控遇到Bug我能看代码“一查到底”不能黑盒化演示稳定性优先宁可功能少一点也不能演示到一半画布崩了。这三条约束直接影响了后面的技术选型。很多人做课设喜欢先搭一堆脚手架再套一堆图形库最后发现真正想画的东西反而不会画。我的建议是先明确渲染的核心需求再来定技术栈顺序不能反。2. 前端架构设计技术选型与模块划分2.1 技术栈选型我为什么定了 Vue3 TS Vite我最后定的组合是Vue 3 TypeScript Vite Pinia渲染用原生Canvas 2D图表用ECharts。为什么这么选其实是一个排除过程。先看渲染方案。电网拓扑的可选方案有SVG、Canvas、WebGL。SVG的优势是DOM节点天然支持事件和样式但节点一多DOM数量会膨胀。三十个节点和几十条线路还好但加上实时闪烁、流动动画之后浏览器会开始掉帧。而且每次数据刷新触发DOM更新性能开销是成倍涨的。WebGL性能最强但课设周期内手写WebGL绘制拓扑调试成本太高而且大部分交互需求用不上GPU。Canvas 2D则正好卡在中间绘制能力强、渲染频率可控、代码自己掌握几十到几百个图元完全扛得住事件交互通过坐标反算做也不复杂。所以定了Canvas 2D。图表部分是ECharts它内部同样是Canvas渲染封装成熟直接用它画曲线省心。框架层面Vue 3的Composition API在组织Canvas这种“命令式状态驱动”的代码时比Vue 2顺手很多。TypeScript的作用是让后端接口返回的电网数据结构在前端有明确类型减少字段名对不上的低级错误。Vite则是启动快、配置简单课设场景不搞复杂构建。考虑过但放弃的方案也写上理由React Redux D3这种组合不是不好而是课设单人开发时D3的声明式坐标系和Canvas命令式绘制混在一起心智负担重Three.js是3D方案我们做的是二维监视屏用不上。2.2 两类状态分开管渲染才有条理这是这次前端架构里我认为最关键的设计把状态分成“电网业务态”和“视图交互态”。电网业务态来自后端包括设备列表、量测数据、告警信息。这部分状态用Pinia管理对应的是“系统当前是什么状态”。视图交互态来自用户操作包括缩放比例、视口偏移、当前选中设备、是否正在拖拽。这部分也放在Pinia里但独立成一个store对应的是“用户当前在看哪里、在操作什么”。为什么要拆开因为渲染循环只需要关心视图交互态。业务数据通过轮询更新时如果直接触发重绘很容易出现“数据刚更新了一半视图就被重绘了”的中间态。拆开以后业务数据的更新只写入store渲染层固定从视图态读取参数主循环统一调度顺序永远不会乱。举个例子一次自动缩放动画执行过程中后端新来了一批遥测数据。如果两个状态混在一起缩放动画的每一帧可能都要被新数据打断重算拆开后缩放动画只订阅视图态的变化数据写入不影响渲染参数动画自然流畅。2.3 模块划分与目录结构前端目录我组织成这样src/ api/ # 后端接口封装 store/ # Pinia stores grid.ts # 电网设备、量测、告警 viewport.ts # 缩放、偏移、选中 render/ layers.ts # canvas层管理 camera.ts # 坐标变换 shapes/ # 母线、线路、变压器等绘制 scheduler.ts # 渲染循环 components/ CanvasStage.vue DevicePanel.vue AlarmList.vue CurvesPanel.vue composables/ useSimulation.ts # 轮询与数据落地 useViewport.ts # 交互事件绑定render目录是纯TypeScript模块不依赖Vue组件全部操作Canvas。components目录只负责搭建DOM骨架和转发事件。这样做的直接好处是Canvas相关的逻辑可以脱离页面独立测试也能在控制台临时调用某个绘制函数排查问题。3. 渲染核心Canvas分层与坐标体系3.1 分层设计别把所有东西画在一层上我一开始偷懒把底图、设备、高亮全部画在同一个Canvas上。结果出现一个不可调和的问题重绘是整帧的画布上任何局部变化比如鼠标移到一个节点上要显示高亮框都必须重画整张图。设备多的时候高频交互会明显卡顿。后来改成三层Canvas效果立竿见影底层baseLayer画电网静态拓扑。母线矩形、变压器图标、输电线路。只在拓扑发生变化比如手动编辑模型时重绘。中间dynamicLayer画实时状态。电压越限节点的颜色、线路潮流方向的流动箭头、告警节点的闪烁每个数据周期更新一次。上层overlayLayer画交互反馈。选中框、悬停提示框、缩放时的临时标记。只在鼠标交互时更新。分层以后最常见的两种重绘场景变成了局部操作数据到了只刷新中间层鼠标移动只刷新上层。底层拓扑基本不重绘。三层Canvas用绝对定位叠在一起代码上用一个layers.ts统一管理创建和尺寸同步。3.2 坐标变换就是把两套坐标串起来电网仿真模型里用的是一套逻辑坐标。IEEE 30节点系统的母线坐标可以归一化到0到100的范围内但我们还需要支持缩放和平移所以画布上实际用的是屏幕坐标。两套坐标之间需要一套可逆的交换关系。核心是一个视口对象包含缩放比例scale和视口原点offsetX/offsetYexport interface Viewport { scale: number; offsetX: number; offsetY: number; } export function toScreen(view: Viewport, logical: Vec2): Vec2 { return { x: (logical.x - view.offsetX) * view.scale, y: (logical.y - view.offsetY) * view.scale, }; } export function toLogical(view: Viewport, screen: Vec2): Vec2 { return { x: screen.x / view.scale view.offsetX, y: screen.y / view.scale view.offsetY, }; }toLogical是toScreen的逆运算两个函数必须严格互逆。后面做选中碰撞检测、鼠标锚点缩放都要靠这一对函数来回换算。如果这里赶时间写歪了后面所有交互都会飘。绘制时永远走逻辑坐标到屏幕坐标的方向交互事件走屏幕坐标到逻辑坐标的方向最终都统一在这两个函数里不要在其他地方再塞单独的换算逻辑。还有一个高分辨率屏幕的问题必须处理不然在Windows上字和图全是糊的。浏览器里canvas的CSS宽度和物理像素宽度在DPR大于1时是两码事需要在初始化时做适配export function setupCanvas(canvas: HTMLCanvasElement, dpr: number) { const rect canvas.getBoundingClientRect(); canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); canvas.style.width rect.width px; canvas.style.height rect.height px; const ctx canvas.getContext(2d)!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }这里的关键是ctx.setTransform(dpr, 0, 0, dpr, 0, 0)。它把坐标系统放大到物理像素然后后续所有绘制代码仍然按CSS像素坐标来写。不写这一步Canvas会拿物理像素当CSS像素用画面就会发虚。以一台DPR为2的电脑为例不做适配的绘制结果只有目标像素的四分之一。3.3 图元绘制母线、线路、变压器、负荷图元分为两类一类是节点类设备有明确的坐标点一类是连接类设备需要连接两个或多个节点。节点类我画的最多的是母线和负荷。母线用矩形表示长方条负荷用三角形发电机用带旋转标记的圆形。每种图元封装成一个独立的绘制函数接收上下文、设备数据、视口、主题色。export function drawBus( ctx: CanvasRenderingContext2D, view: Viewport, device: BusDevice, theme: DeviceTheme ) { const p toScreen(view, device.logicalCoord); const size device.kind generator ? 16 : 12; ctx.save(); ctx.fillStyle theme.fill; ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size); if (device.selected) { ctx.strokeStyle #ffb300; ctx.lineWidth 2; ctx.strokeRect(p.x - size / 2 - 4, p.y - size / 2 - 4, size 8, size 8); } ctx.restore(); }连接类线路我只画了一种样式二次贝塞尔曲线。直线本身也能表达拓扑但多节点互联时直线容易形成复杂的视觉重叠。贝塞尔曲线可以通过控制点偏移制造错位让交叉的线路在视觉上可区分。export function drawLine( ctx: CanvasRenderingContext2D, view: Viewport, from: Vec2, to: Vec2 ) { const p1 toScreen(view, from); const p2 toScreen(view, to); const midX (p1.x p2.x) / 2; const midY (p1.y p2.y) / 2; const dx p2.x - p1.x; const dy p2.y - p1.y; const len Math.hypot(dx, dy) || 1; const ctrl { x: midX - (dy / len) * 24, y: midY (dx / len) * 24, }; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.quadraticCurveTo(ctrl.x, ctrl.y, p2.x, p2.y); ctx.stroke(); }控制点垂直于线段方向偏移24像素是为了让线条带一点弧度。弧度太大会让拓扑显得乱太小等于没偏24在1000像素宽左右的画布上是个稳的数值。变压器我直接画成两个套在一起的小圆连接线从圆心穿过识别率比什么复杂图例都高。实际做的时候建议先把母线坐标在画布上渲染出来核对逻辑坐标和视觉坐标是否吻合再接线路。线路画完再核对交叉处是否有节点遮挡。这个顺序不能省后面的实时数据渲染全部建立在底图正确的前提上。3.4 实时数据刷新怎么做到不卡也不闪实时数据到了以后刷新的核心矛盾就一个渲染频率要高但CPU占用要低。我的做法是“定时器取数 requestAnimationFrame渲染”。取数用setInterval课设的后端是轮询接口我把轮询间隔设在1000毫秒。渲染不跟着轮询走而是单独跑一个rAF循环。rAF循环每次回调只做两件事检查当前是否有数据变更标记有变更就重绘dynamicLayer检查交互状态是否有变化有变化就重绘overlayLayer。这样天然地把“取数频率”和“画面刷新频率”解耦。后端可以某一秒返回大量数据前端不会卡在那一帧动画也可以平滑运行因为动画帧率由rAF决定而不是等数据来了才动。另一个关键点是不要让多个定时器抢着调用重绘。曾经我同时有一个数据定时器和一个闪烁定时器两个都在调drawAll结果闪烁一开整个画面每200毫秒整体重画一次。现在我所有渲染请求都通过scheduler来统一调度class Scheduler { private dirty { base: false, dynamic: false, overlay: false }; private rafId 0; request(layer: LayerType) { this.dirty[layer] true; if (!this.rafId) { this.rafId requestAnimationFrame(this.flush.bind(this)); } } private flush() { this.rafId 0; if (this.dirty.base) { redrawBase(); this.dirty.base false; } if (this.dirty.dynamic) { redrawDynamic(); this.dirty.dynamic false; } if (this.dirty.overlay) { redrawOverlay(); this.dirty.overlay false; } } }这种方式还有个好处同时收到多个数据更新请求时rAF会把它们合并到同一帧绘制不会出现“画了一半又被另一份数据打断”的闪烁。现实中这个点特别容易出现只要有两个不同的定时器画面就会间歇性闪排查半天最后发现是重绘互相打架。4. 前端交互与数据联动4.1 缩放和平移锚点计算是精度重灾区Canvas的缩放平移视觉上很简单但我第一版实现花了最多时间才改对。因为很多人包括我在内一开始都只更新了scale忽略了鼠标位置对应的逻辑坐标也必须保持不变。锚点缩放的公式下面这个版本是最终调通的export function zoomAt( view: Viewport, screenX: number, screenY: number, factor: number ): Viewport { const newScale clamp(view.scale * factor, 0.15, 8); const logicalX screenX / view.scale view.offsetX; const logicalY screenY / view.scale view.offsetY; view.offsetX logicalX - screenX / newScale; view.offsetY logicalY - screenY / newScale; view.scale newScale; return view; }逻辑是先把鼠标的屏幕坐标反算到逻辑坐标缩放后再重新算视口偏移保证鼠标下的这个逻辑点在缩放前后仍然留在鼠标位置。如果不重新设置offsetX/offsetY缩放就是以画布原点为中心图形会明显“漂”走。平移的实现反而简单pointerdown记录起点和当前视口偏移pointermove阶段用增量更新offsetpointerup收尾。中间注意在pointerdown后调用setPointerCapture这样鼠标移出画布也能继续跟踪拖拽。缩放的min/max值我设的是0.15到8。太小了整个拓扑缩成一个点点击和选中的命中难度变大太大会出现线路的贝塞尔控制点溢出可视区域反而不好看。4.2 设备选中与详情弹窗全靠反算命中Canvas不像SVG那样每个元素自带事件。要判断鼠标点中了哪个设备思路是把屏幕坐标反算成逻辑坐标再遍历设备做碰撞检测。我的设备命中分两个层级先按矩形区域判断命中后如果设备是线路再做点到线段距离判断。矩形判断覆盖了母线、负荷这些面积较大的设备线路需要单独处理因为线路本身是一条带弧度的线直接按矩形命中会出现大片误选。export function hitTest( devices: Device[], logicalPoint: Vec2, threshold: number ): string | null { for (const dev of devices) { if (dev.type node) { const d Math.hypot( dev.logicalCoord.x - logicalPoint.x, dev.logicalCoord.y - logicalPoint.y ); if (d threshold) return dev.id; } // line: 用二次贝塞尔采样 距离判断 } return null; }threshold不是固定值它与当前缩放比例有关。屏幕上一个8像素的命中容差在逻辑坐标系里对应的是8/scale。我把threshold定义为scale的倒数乘以一个常数这样缩放后命中手感一致。选中以后高亮框绘制在overlayLayer上同时右侧DevicePanel显示设备详情。详情内容来自网格store里对应设备的最新量测数据这里直接把设备ID作为key去查store画面上的高亮和面板上的数值天然同步。4.3 潮流曲线与告警闪烁曲线部分我直接用ECharts。官方推荐的是按需引入模块课设里只用了line和grid打包出来很小。曲线数据放在一个环形缓冲区里固定保留最近300个时间点的采样值满足“看趋势变化”的需求就够了。告警闪烁画在dynamicLayer上。闪烁的实现是在scheduler的rAF循环里读当前时间戳对需要闪烁的设备用时间相位做透明度控制const phase (performance.now() / 500) % 1; const alpha phase 0.5 ? 1 : 0.3;500毫秒一个周期视觉上刚好能引起注意又不刺眼。相位0到0.5是全亮0.5到1是半透明循环往复。这类动画千万别用setInterval单独驱动它会和主rAF循环抢帧把闪烁做成“一顿一顿”的效果。数据联动上我踩过一个挺隐蔽的坑。最开始告警列表和画布是分开刷新的导致画布上节点已经闪烁了列表还要等下一轮轮询才出现。解决办法是让告警列表不直接订阅store的某个简单字段而是订阅一个“告警版本号”version。数据更新时version加一列表组件watch这个version然后重拉数据。这样画布和列表即使各自独立渲染也能在同一个数据周期内保持一致。5. 常见问题与排查技巧实录5.1 高分屏模糊先检查物理像素界面在普通屏上看着正常放到笔记本高分屏上就发虚这类问题九成是没做DPR适配。最快排查方法是打开浏览器开发者工具在Elements面板选中canvas元素看canvas.width和canvas.height属性值然后在Console里比较canvas.getBoundingClientRect().width和canvas.width的比值如果比值等于DPR就正常等于1就是没适配。规律是canvas.width等于CSS宽度时一定模糊。处理方式就是前面setupCanvas里写的setTransform一定要在绘制前执行一次。注意不要在ResizeObserver回调里重复执行否则会不断重置画布出现新的闪烁。5.2 缩放后图形漂移对锚点做反向验证漂移的本质是toScreen和toLogical这对变换不自洽。我踩过最隐蔽的一处是在zoomAt里计算逻辑坐标时用了旧scale但在更新offset时用了newScale两者混在一起必然导致漂移。排查有一个笨但高效的验证法在鼠标锚点处画一个固定标记点缩放后看标记是否还粘在鼠标下。粘住了就是公式对没粘住就逐步打印offset和scale的中间值定位是哪一步写错。这个标记点放在overlayLayer排查完删掉就行。5.3 数据刷新掉帧与内存泄漏掉帧先看是不是多条重绘路径同时在跑。打开Performance录一段如果能看到大量短促的RAF回调挤在一起基本就是多个定时器各调各的渲染。统一收敛到scheduler里问题立即消失。内存泄漏主要盯三处setInterval没有清理组件卸载了但定时器还在跑Canvas的离屏缓存只增不减每次重绘都新建离屏画布但不释放ECharts实例没有调用dispose。课设周期短前两处最容易发生尤其是第二处写着写着很容易在图层切换里不断new一个大尺寸Canvas。5.4 页面不可见时定时器被节流用户把页面切到后台再切回来经常发现画面停留在好几分钟前的状态。原因是浏览器对不可见页面里的setInterval自动节流最小间隔被拉到1秒甚至更久。我的处理方案是监听visibilitychange事件页面重新可见时立即拉一次最新数据并立刻触发一次渲染。同时把轮询内的增量diff逻辑保留住避免重复数据重复写入引起额外重绘。最后说几个和具体功能无关、但对这次课设帮助很大的心得。前端这部分最难的不是Canvas API用得不熟而是“数据模型”和“渲染模型”之间那个映射层。只要后端字段一改动整个前端都要跟着调整。我现在回头看真正节省时间的是动手写代码之前把IEEE 30节点的数据结构完整梳理了一遍并和渲染层约定好接口。Canvas绘制的每一个图元、每一个状态颜色都有对应的数据字段。排除了几十次“因为字段名不一致导致画面异常”的低级错误。另外一个体会是课设和上线项目不同不需要追求完整的产品包装但“演示不出错”这件事值得多花时间打磨。把画布分层、把渲染调度收拢、把状态管理理顺这三件事做完整个前端不管是加功能还是修Bug都变得很好说话。如果你也在做类似的电网模拟课设建议优先把这三块做成再考虑界面好不好看。