波形解析相关

This commit is contained in:
2026-04-17 08:10:46 +08:00
parent 649418a51c
commit e4051cf151
6 changed files with 621 additions and 83 deletions

View File

@@ -34,7 +34,10 @@
:has-parsed-waveform="hasParsedWaveform"
:summary-items="summaryItems"
:feature-cards="featureCards"
:vector-parse-result="vectorParseResult"
:last-parse-error-message="lastParseErrorMessage"
:last-vector-parse-error-message="lastVectorParseErrorMessage"
:active-vector-channel-name="activeWaveDetail?.channelName || ''"
/>
</div>
</div>
@@ -42,8 +45,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
import { ElMessage } from 'element-plus'
import { parseComtradeApi } from '@/api/tools/waveform'
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
import type { Waveform } from '@/api/tools/waveform/interface'
import WaveformInfoPanel from './components/WaveformInfoPanel.vue'
import WaveformToolbar from './components/WaveformToolbar.vue'
@@ -89,7 +93,9 @@ const isParsing = ref(false)
const selectedCfgFile = ref<File | null>(null)
const selectedDatFile = ref<File | null>(null)
const waveformParseResult = ref<Waveform.WaveComtradeResultVO | null>(null)
const vectorParseResult = ref<Waveform.WaveComtradeVectorResultVO | null>(null)
const lastParseErrorMessage = ref('')
const lastVectorParseErrorMessage = ref('')
const waveformFileAccept = '.cfg,.dat'
const trendTabs: LabelValueOption<TrendTabValue>[] = [
@@ -257,6 +263,13 @@ const formatDuration = (value?: number) => {
return `${Number(numberValue.toFixed(3))} ms`
}
const formatWaveformTime = (value?: string) => {
if (!value) return '--'
const parsedValue = dayjs(value)
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
}
const buildChannelLabel = (detail: Waveform.WaveDataDetail, index: number) => {
if (detail.title) return detail.title
if (detail.channelName && detail.unit) return `${detail.channelName} (${detail.unit})`
@@ -310,22 +323,96 @@ const currentTrendColors = computed(() => {
const hasWaveformData = computed(() => activeTrendPayload.value.series.length > 0)
const SYMMETRIC_AXIS_SPLIT_COUNT = 6
const REGULAR_AXIS_SPLIT_COUNT = 5
const getAxisPrecision = (step: number) => {
if (!Number.isFinite(step) || step >= 1) return 0
const stepText = `${step}`
if (stepText.includes('e-')) {
return Number(stepText.split('e-')[1] || 0)
}
return stepText.split('.')[1]?.length || 0
}
const normalizeAxisValue = (value: number, precision: number) => {
const factor = 10 ** precision
const normalizedValue = Math.round(value * factor) / factor
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
}
const getNiceAxisInterval = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return 1
const exponent = Math.floor(Math.log10(value))
const magnitude = 10 ** exponent
const normalized = value / magnitude
const candidates = [1, 2, 2.5, 5, 10]
const closestCandidate = candidates.reduce((currentBest, currentValue) => {
return Math.abs(currentValue - normalized) < Math.abs(currentBest - normalized) ? currentValue : currentBest
}, candidates[0])
return closestCandidate * magnitude
}
const roundAxisBoundary = (value: number, interval: number, isMin: boolean) => {
const precision = getAxisPrecision(interval)
const stepCount = isMin ? Math.floor(value / interval) : Math.ceil(value / interval)
return normalizeAxisValue(stepCount * interval, precision)
}
const formatAxisLabel = (value: number, precision: number) => {
if (!Number.isFinite(value)) return ''
return `${normalizeAxisValue(value, precision)}`
}
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
const min = trendPayload.min
const max = trendPayload.max
const valueGap = Number.isFinite(min) && Number.isFinite(max) ? Math.max((max! - min!) * 0.1, 1) : undefined
const min = Number(trendPayload.min)
const max = Number(trendPayload.max)
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return {
name: trendPayload.unit || ''
}
}
const crossesZero = min < 0 && max > 0
const splitCount = crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : REGULAR_AXIS_SPLIT_COUNT
const baseValue = crossesZero ? Math.max(Math.abs(min), Math.abs(max)) : max - min
const interval = getNiceAxisInterval(baseValue / (crossesZero ? splitCount / 2 : splitCount))
const precision = getAxisPrecision(interval)
let axisMin = roundAxisBoundary(min, interval, true)
let axisMax = roundAxisBoundary(max, interval, false)
if (crossesZero) {
const axisBound = Math.max(Math.abs(axisMin), Math.abs(axisMax))
axisMin = normalizeAxisValue(-axisBound, precision)
axisMax = normalizeAxisValue(axisBound, precision)
}
if (axisMin === axisMax) {
axisMin = normalizeAxisValue(axisMin - interval, precision)
axisMax = normalizeAxisValue(axisMax + interval, precision)
}
return {
name: trendPayload.unit || '',
...(valueGap !== undefined && min !== undefined && max !== undefined
? {
min: min - valueGap,
max: max + valueGap
}
: {})
min: axisMin,
max: axisMax,
interval,
splitNumber: crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : Math.max(Math.round((axisMax - axisMin) / interval), 1),
minInterval: interval,
maxInterval: interval,
// 跨零波形按 0 对称出刻度,避免边界出现 164 / -165 这类不均匀标签。
axisLabel: {
hideOverlap: true,
formatter: (value: number) => formatAxisLabel(value, precision)
}
}
}
const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1
@@ -360,14 +447,14 @@ const buildTrendChartOptions = (
}
},
legend: {
top: 10,
top: 0,
right: 12
},
grid: {
top: '18px',
left: '24px',
right: showTimeAxis ? '32px' : '24px',
bottom: showTimeAxis ? '22px' : '12px'
bottom: showTimeAxis ? '16px' : '6px'
},
xAxis: {
data: trendPayload.timeLabels,
@@ -381,6 +468,8 @@ const buildTrendChartOptions = (
},
axisLine: {
show: showTimeAxis,
// 时间轴固定在图底部,避免 y 轴包含 0 时横轴穿过波形区域显得过粗。
onZero: false,
lineStyle: {
color: axisLineColor
}
@@ -389,7 +478,7 @@ const buildTrendChartOptions = (
show: showTimeAxis,
hideOverlap: false,
interval: 0,
margin: showTimeAxis ? 6 : 0,
margin: showTimeAxis ? 9 : 0,
color: axisTextColor,
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
},
@@ -445,18 +534,15 @@ const summaryItems = computed<SummaryItem[]>(() => {
const detail = activeWaveDetail.value
return [
{ label: '测点名称', value: waveData?.monitorName || '--' },
{ label: '事件时间', value: waveData?.time || '--' },
{ label: '录波开始', value: cfgData?.timeStart || '--' },
{ label: '触发时间', value: cfgData?.timeTrige || '--' },
{ label: '录波开始', value: formatWaveformTime(cfgData?.timeStart) },
{ label: '触发时间', value: formatWaveformTime(cfgData?.timeTrige) },
{ label: '采样率', value: cfgData?.finalSampleRate ? `${cfgData.finalSampleRate} Hz` : '--' },
{ label: '总通道数', value: cfgData?.nChannelNum ?? '--' },
{ label: '当前通道', value: detail ? buildChannelLabel(detail, activeChannelIndex.value) : '--' },
{ label: '单位', value: detail?.unit || '--' },
{ label: '相别数量', value: waveData?.iPhasic ?? '--' },
{ label: 'PT / CT', value: `${formatNumber(waveData?.pt, 2)} / ${formatNumber(waveData?.ct, 2)}` },
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] },
{ label: '文件编码', value: cfgData?.strBinType || '--' }
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] }
]
})
@@ -480,7 +566,9 @@ const resetSelectedWaveformFiles = () => {
selectedCfgFile.value = null
selectedDatFile.value = null
waveformParseResult.value = null
vectorParseResult.value = null
lastParseErrorMessage.value = ''
lastVectorParseErrorMessage.value = ''
}
const handleWaveformFileChange = async (event: Event) => {
@@ -524,24 +612,50 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
isParsing.value = true
activeChannelIndex.value = 0
lastParseErrorMessage.value = ''
lastVectorParseErrorMessage.value = ''
const result = await parseComtradeApi({
cfgFile,
datFile
})
// 波形与向量结果共用同一组文件,这里并行请求以缩短右侧联动展示等待时间。
const [waveformResult, vectorResult] = await Promise.allSettled([
parseComtradeApi({
cfgFile,
datFile
}),
parseComtradeVectorApi({
cfgFile,
datFile,
parseType: 3
})
])
waveformParseResult.value = result.data
} catch (error) {
waveformParseResult.value = null
lastParseErrorMessage.value = getWaveformParseErrorMessage(error)
if (waveformResult.status === 'fulfilled') {
waveformParseResult.value = waveformResult.value.data
} else {
waveformParseResult.value = null
lastParseErrorMessage.value = getWaveformParseErrorMessage(waveformResult.reason)
console.error('[waveform] parseComtrade failed', {
cfgFileName: cfgFile.name,
cfgFileSize: cfgFile.size,
datFileName: datFile.name,
datFileSize: datFile.size,
error
})
console.error('[waveform] parseComtrade failed', {
cfgFileName: cfgFile.name,
cfgFileSize: cfgFile.size,
datFileName: datFile.name,
datFileSize: datFile.size,
error: waveformResult.reason
})
}
if (vectorResult.status === 'fulfilled') {
vectorParseResult.value = vectorResult.value.data
} else {
vectorParseResult.value = null
lastVectorParseErrorMessage.value = getWaveformParseErrorMessage(vectorResult.reason)
console.error('[waveform] parseComtradeVector failed', {
cfgFileName: cfgFile.name,
cfgFileSize: cfgFile.size,
datFileName: datFile.name,
datFileSize: datFile.size,
error: vectorResult.reason
})
}
} finally {
isParsing.value = false
}
@@ -586,7 +700,7 @@ const downloadTrendData = () => {
.waveform-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.95fr);
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.72fr);
gap: 16px;
width: 100%;
height: 100%;
@@ -600,10 +714,3 @@ const downloadTrendData = () => {
}
</style>