feat: 添加 EGM 计算动画可视化功能

在 web 界面中实现 EGM 计算过程的动画展示,包括地线保护弧、导线暴露弧和地面线的动态绘制。重构 main.py 以支持可选的动画参数传递,并新增 Animation.vue 组件和 WebAnimation 类实现前后端交互。
This commit is contained in:
dmy
2026-03-03 15:58:57 +08:00
parent cee451914a
commit a65ce23cee
5 changed files with 409 additions and 18 deletions

View File

@@ -4,4 +4,4 @@
<script setup lang="ts">
import ParameterForm from '@/components/ParameterForm.vue'
</script>
</script>

View File

@@ -0,0 +1,258 @@
<template>
<q-card class="shadow-2">
<q-card-section class="bg-indigo-50">
<div class="text-h6 text-indigo-900 flex items-center gap-2">
<q-icon name="animation" />
EGM 动画可视化
<q-space />
<q-toggle
v-model="enabled"
label="启用动画"
color="primary"
@update:model-value="onEnableChange"
/>
</div>
</q-card-section>
<q-card-section>
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
class="animation-canvas"
/>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// Canvas 尺寸
const canvasWidth = 600
const canvasHeight = 600
// 坐标范围(对应 animation.py 的 [-500, 500]
const coordRange = {
xMin: -500,
xMax: 500,
yMin: -500,
yMax: 500
}
// 启用/禁用动画
const enabled = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
let tick = 0
// 坐标转换:数据坐标 -> Canvas 坐标
const toCanvasX = (x: number): number => {
return ((x - coordRange.xMin) / (coordRange.xMax - coordRange.xMin)) * canvasWidth
}
const toCanvasY = (y: number): number => {
// Canvas Y 轴向下,需要反转
return canvasHeight - ((y - coordRange.yMin) / (coordRange.yMax - coordRange.yMin)) * canvasHeight
}
// 初始化画布
const initFig = () => {
if (!ctx || !enabled.value) return
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
// 绘制坐标轴
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
// X 轴
ctx.beginPath()
ctx.moveTo(0, toCanvasY(0))
ctx.lineTo(canvasWidth, toCanvasY(0))
ctx.stroke()
// Y 轴
ctx.beginPath()
ctx.moveTo(toCanvasX(0), 0)
ctx.lineTo(toCanvasX(0), canvasHeight)
ctx.stroke()
// 绘制刻度
ctx.fillStyle = '#666'
ctx.font = '10px Arial'
ctx.textAlign = 'center'
for (let x = coordRange.xMin; x <= coordRange.xMax; x += 100) {
if (x !== 0) {
const canvasX = toCanvasX(x)
ctx.fillText(x.toString(), canvasX, toCanvasY(0) + 15)
}
}
ctx.textAlign = 'right'
for (let y = coordRange.yMin; y <= coordRange.yMax; y += 100) {
if (y !== 0) {
const canvasY = toCanvasY(y)
ctx.fillText(y.toString(), toCanvasX(0) - 5, canvasY + 3)
}
}
}
// 清除画布
const clear = () => {
if (!ctx) return
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
}
// 添加 RG 线(地面线)
const addRgLine = (points: [number, number][]) => {
if (!ctx || !enabled.value || !points || points.length === 0) return
ctx.strokeStyle = '#2196F3'
ctx.lineWidth = 2
ctx.beginPath()
points.forEach((point, index) => {
const canvasX = toCanvasX(point[0])
const canvasY = toCanvasY(point[1])
if (index === 0) {
ctx!.moveTo(canvasX, canvasY)
} else {
ctx!.lineTo(canvasX, canvasY)
}
})
ctx.stroke()
}
// 添加 RS 圆(地线保护弧)- 这是每帧第一个绘制的元素,先清除画布
const addRs = (rs: number, rsX: number, rsY: number) => {
if (!ctx || !enabled.value) return
// 清除并重新初始化画布,准备绘制新的一帧
clear()
initFig()
const canvasX = toCanvasX(rsX)
const canvasY = toCanvasY(rsY)
const canvasRadius = rs * (canvasWidth / (coordRange.xMax - coordRange.xMin))
ctx.strokeStyle = '#4CAF50'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(canvasX, canvasY, canvasRadius, 0, Math.PI * 2)
ctx.stroke()
}
// 添加 RC 圆(导线暴露弧)
const addRc = (rc: number, rcX: number, rcY: number) => {
if (!ctx || !enabled.value) return
const canvasX = toCanvasX(rcX)
const canvasY = toCanvasY(rcY)
const canvasRadius = rc * (canvasWidth / (coordRange.xMax - coordRange.xMin))
ctx.strokeStyle = '#FF9800'
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(canvasX, canvasY, canvasRadius, 0, Math.PI * 2)
ctx.stroke()
}
// 添加暴露弧区域(两条红线)
const addExposeArea = (
rcX: number,
rcY: number,
intersectionX1: number,
intersectionY1: number,
intersectionX2: number,
intersectionY2: number
) => {
if (!ctx || !enabled.value) return
ctx.strokeStyle = '#F44336'
ctx.lineWidth = 3
// 第一条线
ctx.beginPath()
ctx.moveTo(toCanvasX(rcX), toCanvasY(rcY))
ctx.lineTo(toCanvasX(intersectionX1), toCanvasY(intersectionY1))
ctx.stroke()
// 第二条线
ctx.beginPath()
ctx.moveTo(toCanvasX(rcX), toCanvasY(rcY))
ctx.lineTo(toCanvasX(intersectionX2), toCanvasY(intersectionY2))
ctx.stroke()
}
// 暂停并刷新 - 用于下一帧绘制前清除
const pause = () => {
if (!ctx || !enabled.value) return
tick += 1
// 不立即清除,等待下一次绑图时清除
// 这样用户可以看到当前帧
}
// 启用状态改变
const onEnableChange = (value: boolean) => {
if (value) {
initFig()
} else {
clear()
}
}
// 暴露方法给父组件或全局调用
const animationApi = {
enable: (enable: boolean) => {
enabled.value = enable
onEnableChange(enable)
},
initFig,
clear,
addRgLine,
addRs,
addRc,
addExposeArea,
pause
}
// 注册到全局,供后端调用
declare global {
interface Window {
animationApi?: typeof animationApi
}
}
onMounted(() => {
if (canvasRef.value) {
ctx = canvasRef.value.getContext('2d')
if (enabled.value) {
initFig()
}
}
// 注册全局 API
window.animationApi = animationApi
})
onUnmounted(() => {
window.animationApi = undefined
})
// 导出方法供父组件使用
defineExpose(animationApi)
</script>
<style scoped>
.animation-canvas {
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fafafa;
display: block;
margin: 0 auto;
}
</style>

View File

@@ -351,6 +351,9 @@
<!-- 运行日志 -->
<LogComponent ref="logRef" />
<!-- EGM 动画可视化 -->
<Animation ref="animationRef" class="q-mt-md" />
</div>
</q-page>
</q-page-container>
@@ -361,6 +364,7 @@
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import type { AllParameters } from '@/types'
import LogComponent from './Log.vue'
import Animation from './Animation.vue'
// 默认参数
const defaultParams: AllParameters = {
@@ -394,6 +398,7 @@ const calculating = ref(false)
const result = ref<{ tripping_rate: number; n_sf_phases: number[]; message: string } | null>(null)
const error = ref<string | null>(null)
const logRef = ref<InstanceType<typeof LogComponent> | null>(null)
const animationRef = ref<InstanceType<typeof Animation> | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
// 雷电流概率密度系数设置开关
const showIpCoefficients = ref(false)