Files
CN_Tool_client/frontend/src/views/tools/waveform/index.vue
yexb fe3ab1f679 refactor(waveform): 优化波形趋势图表交互功能
- 添加了 canResetTrendChart 计算属性用于判断是否可以重置图表
- 更新了工具栏状态控制逻辑,禁用状态下禁止重置操作
- 调整了图表边距配置,右侧留白从 18px 增加到 36px
- 时间轴底部留白从 30px 调整为 34px
- 时间轴标签间距从 6 调整为 8
- 集成了 ECharts 工具箱的数据缩放功能
- 替换了图标组件,使用箭头图标替代缩放图标
- 调整了工具栏项目顺序,将平移工具移到最后
2026-05-07 11:47:44 +08:00

1225 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="table-box waveform-page">
<WaveformToolbar
:selected-waveform-file-name="selectedWaveformFileName"
:is-parsing="isParsing"
:waveform-file-accept="waveformFileAccept"
:channel-options="channelOptions"
:active-channel-index="activeChannelIndex"
:display-mode-options="displayModeOptions"
:active-display-mode="activeDisplayMode"
:value-mode-options="valueModeOptions"
:active-value-mode="activeValueMode"
@update:active-channel-index="activeChannelIndex = $event"
@update:active-display-mode="activeDisplayMode = $event"
@update:active-value-mode="activeValueMode = $event"
@waveform-file-change="handleWaveformFileChange"
/>
<div class="waveform-layout">
<WaveformTrendPanel
:has-waveform-data="hasWaveformData"
:active-display-mode="activeDisplayMode"
:active-trend-tab="activeTrendTab"
:trend-tabs="trendTabs"
:active-trend-options="activeTrendOptions"
:single-channel-trend-options-list="singleChannelTrendOptionsList"
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
:active-trend-tool-states="activeTrendToolStates"
:disabled-trend-tool-states="disabledTrendToolStates"
@update:active-trend-tab="activeTrendTab = $event"
@trend-tool-action="handleTrendToolAction"
@chart-data-zoom="handleTrendChartDataZoom"
/>
<WaveformInfoPanel
:has-parsed-waveform="hasParsedWaveform"
:summary-items="summaryItems"
:vector-parse-result="vectorParseResult"
:last-parse-error-message="lastParseErrorMessage"
:last-vector-parse-error-message="lastVectorParseErrorMessage"
:active-vector-channel-name="activeWaveDetail?.channelName || ''"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import dayjs from 'dayjs'
import html2canvas from 'html2canvas'
import { ElMessage } from 'element-plus'
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'
import WaveformTrendPanel from './components/WaveformTrendPanel.vue'
import type {
AllChannelTrendGroup,
ChannelSelectValue,
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
SummaryItem,
TrendChartZoomPayload,
TrendToolAction,
TrendTabValue,
ValueMode,
WaveformDetailOption
} from './components/types'
defineOptions({
name: 'WaveformView'
})
interface WaveformSeriesItem {
name: string
data: number[]
}
interface WaveformTrendPayload {
timeLabels: string[]
unit: string
min?: number
max?: number
series: WaveformSeriesItem[]
}
interface TrendChartLayoutOptions {
showTimeAxis?: boolean
yAxisSplitCount?: number
}
interface TrendZoomRange {
start: number
end: number
}
const activeTrendTab = ref<TrendTabValue>('instant')
const activeValueMode = ref<ValueMode>('primary')
const activeDisplayMode = ref<DisplayMode>('single-channel')
const activeChannelIndex = ref<ChannelSelectValue>('all')
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
const allChannelTrendChartGroup = 'waveform-all-channel-sync'
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 trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
const trendYZoomScale = ref(1)
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan'>('none')
const trendTabs: LabelValueOption<TrendTabValue>[] = [
{ value: 'instant', label: '瞬时波形' },
{ value: 'rms', label: 'RMS 波形' }
]
const valueModeOptions: LabelValueOption<ValueMode>[] = [
{ label: '一次值', value: 'primary' },
{ label: '二次值', value: 'secondary' }
]
const displayModeOptions: LabelValueOption<DisplayMode>[] = [
{ label: '单通道', value: 'single-channel' },
{ label: '多通道', value: 'multi-channel' }
]
const trendLabelMap: Record<TrendTabValue, string> = {
instant: '瞬时波形',
rms: 'RMS 波形'
}
const valueModeLabelMap: Record<ValueMode, string> = {
primary: '一次值',
secondary: '二次值'
}
const readThemeColor = (name: string, fallback: string) => {
if (typeof window === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
const phaseColors = {
a: readThemeColor('--cn-color-phase-a', '#daa520'),
b: readThemeColor('--cn-color-phase-b', '#2e8b57'),
c: readThemeColor('--cn-color-phase-c', '#a52a2a')
}
const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')
const selectedWaveformFileName = computed(() => {
const fileNames = [selectedCfgFile.value?.name, selectedDatFile.value?.name].filter(Boolean)
return fileNames.join(' / ')
})
const getWaveformParseErrorMessage = (error: unknown) => {
if (!error || typeof error !== 'object') {
return '波形解析失败,请检查 cfg 和 dat 文件内容'
}
const businessError = error as {
message?: string
response?: {
data?: {
message?: string
}
}
}
return businessError.response?.data?.message || businessError.message || '波形解析失败,请检查 cfg 和 dat 文件内容'
}
const hasParsedWaveform = computed(() => !!waveformParseResult.value?.waveData)
const buildSeriesPoints = (list: number[][] | undefined, valueIndex: number) => {
return (list || []).map(item => [Number(item[0]), Number(item[valueIndex] ?? 0)])
}
const normalizedWaveDetails = computed<Waveform.WaveDataDetail[]>(() => {
const waveData = waveformParseResult.value?.waveData
const detailList = waveformParseResult.value?.waveDataDetails || []
const instantList = waveData?.listWaveData
const rmsList = waveData?.listRmsData
const waveTitles = waveData?.waveTitle || []
const groupCount = Math.max(Math.floor(Math.max(waveTitles.length - 1, 0) / 3), detailList.length)
if (!groupCount) return []
return Array.from({ length: groupCount }, (_, index) => {
const startColumnIndex = index * 3 + 1
const detail = detailList[index]
const titleSlice = waveTitles.slice(startColumnIndex, startColumnIndex + 3)
return {
channelName: detail?.channelName || waveData?.channelNames?.[startColumnIndex] || `通道${index + 1}`,
title: detail?.title || titleSlice.join(' / ') || `三相波形 ${index + 1}`,
unit: detail?.unit || '',
a: detail?.a || waveTitles[startColumnIndex] || 'A相',
b: detail?.b || waveTitles[startColumnIndex + 1] || 'B相',
c: detail?.c || waveTitles[startColumnIndex + 2] || 'C相',
isOpen: detail?.isOpen || false,
// 三相颜色统一读取全局主题变量,避免被接口返回的局部颜色覆盖。
colors: [...defaultPhaseColors],
instantData: {
aValue: buildSeriesPoints(instantList, startColumnIndex),
bValue: buildSeriesPoints(instantList, startColumnIndex + 1),
cValue: buildSeriesPoints(instantList, startColumnIndex + 2)
},
rmsData: {
aValue: buildSeriesPoints(rmsList, startColumnIndex),
bValue: buildSeriesPoints(rmsList, startColumnIndex + 1),
cValue: buildSeriesPoints(rmsList, startColumnIndex + 2)
}
}
})
})
const channelOptions = computed<WaveformDetailOption[]>(() => {
const detailOptions = normalizedWaveDetails.value.map((item, index) => ({
label: buildChannelLabel(item, index),
value: index
}))
return detailOptions.length ? [{ label: '全部', value: 'all' }, ...detailOptions] : []
})
const isAllChannelsActive = computed(() => activeChannelIndex.value === 'all')
const activeWaveDetail = computed(() => {
if (typeof activeChannelIndex.value !== 'number') return null
return normalizedWaveDetails.value[activeChannelIndex.value] || normalizedWaveDetails.value[0] || null
})
const activeWaveData = computed(() => waveformParseResult.value?.waveData)
const normalizeRatio = (value?: number) => {
const ratio = Number(value)
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1
}
const isCurrentChannel = (channelName?: string) => {
return (channelName || '').toUpperCase().startsWith('I')
}
const getValueScale = (detail: Waveform.WaveDataDetail | null) => {
if (activeValueMode.value === 'secondary') return 1
const waveData = activeWaveData.value
const ratio = isCurrentChannel(detail?.channelName) ? waveData?.ct : waveData?.pt
return normalizeRatio(ratio)
}
const activeValueScale = computed(() => getValueScale(activeWaveDetail.value))
const safeNumber = (value: unknown) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const formatNumber = (value: unknown, fractionDigits = 3) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '--'
if (Number.isInteger(numberValue)) return `${numberValue}`
return `${Number(numberValue.toFixed(fractionDigits))}`
}
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})`
if (detail.channelName) return detail.channelName
return `通道 ${index + 1}`
}
const buildTrendPayload = (
detail: Waveform.WaveDataDetail | null,
trendTab: TrendTabValue,
scale: number
): WaveformTrendPayload => {
const trendData = trendTab === 'instant' ? detail?.instantData : detail?.rmsData
const aName = detail?.a || 'A相'
const bName = detail?.b || 'B相'
const cName = detail?.c || 'C相'
const aValue = trendData?.aValue || []
const bValue = trendData?.bValue || []
const cValue = trendData?.cValue || []
const timeSource = aValue.length ? aValue : bValue.length ? bValue : cValue
const seriesConfigs = [
{ name: aName, source: aValue },
{ name: bName, source: bValue },
{ name: cName, source: cValue }
]
const series = seriesConfigs
.filter(item => item.source.length)
.map(item => ({
name: item.name,
data: item.source.map(point => safeNumber(point[1]) * scale)
}))
const flatSeriesData = series.flatMap(item => item.data)
const min = flatSeriesData.length ? Math.min(...flatSeriesData) : undefined
const max = flatSeriesData.length ? Math.max(...flatSeriesData) : undefined
return {
timeLabels: timeSource.map(point => formatNumber(point[0])),
unit: detail?.unit || '',
min,
max,
series
}
}
const activeTrendPayload = computed(() => {
return buildTrendPayload(activeWaveDetail.value, activeTrendTab.value, activeValueScale.value)
})
const getTrendColors = (detail: Waveform.WaveDataDetail | null) => {
const customColors = detail?.colors?.filter(Boolean) || []
return customColors.length >= 3 ? customColors.slice(0, 3) : defaultPhaseColors
}
const currentTrendColors = computed(() => getTrendColors(activeWaveDetail.value))
const hasWaveformData = computed(() => {
if (isAllChannelsActive.value) return allChannelTrendGroups.value.length > 0
return activeTrendPayload.value.series.length > 0
})
const canPanTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
return hasWaveformData.value && (start > 0 || end < 100)
})
const canResetTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
return (
hasWaveformData.value &&
(start > 0 || end < 100 || trendYZoomScale.value !== 1 || activeTrendInteractionMode.value !== 'none')
)
})
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
pan: activeTrendInteractionMode.value === 'pan'
}))
const disabledTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
pan: !canPanTrendChart.value,
reset: !canResetTrendChart.value
}))
const TREND_AXIS_EXPAND_RATIO = 1.2
const TREND_AXIS_SHRINK_RATIO = 0.8
const TREND_AXIS_BALANCED_RATIO = 0.9
const TREND_AXIS_DEFAULT_SPLIT_COUNT = 4
const TREND_AXIS_COMPACT_SPLIT_COUNT = 2
const TREND_AXIS_SMALL_INTERVAL_THRESHOLD = 1
const TREND_AXIS_EXTRA_SPLIT_SCORE = 0.25
const TREND_AXIS_READABLE_INTERVAL_STEPS = [1, 2, 2.5, 5, 10]
const TREND_GRID_TOP = '6px'
const TREND_GRID_LEFT = '52px'
const TREND_GRID_RIGHT = '36px'
const TREND_GRID_BOTTOM = {
withTimeAxis: '34px',
withoutTimeAxis: '6px'
}
const TREND_LINE_MAX_WIDTH = 1.6
const getAxisPrecision = (step: number) => {
const absStep = Math.abs(step)
if (!Number.isFinite(absStep) || absStep >= 1) return 0
const stepText = `${absStep}`
if (stepText.includes('e-')) {
return Number(stepText.split('e-')[1] || 0)
}
return Math.min(stepText.split('.')[1]?.length || 0, 4)
}
const getAxisBoundaryPrecision = (axisMin: number, axisMax: number, interval: number) => {
const boundaryPrecision = Math.max(Math.abs(axisMin), Math.abs(axisMax)) < 1 ? 2 : 0
return Math.max(getAxisPrecision(interval), boundaryPrecision)
}
const getReadableAxisInterval = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return 1
const magnitude = 10 ** Math.floor(Math.log10(value))
const normalizedValue = value / magnitude
const step = TREND_AXIS_READABLE_INTERVAL_STEPS.find(item => normalizedValue <= item) || 10
return step * magnitude
}
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 roundAxisValueUp = (value: number) => {
const absValue = Math.abs(value)
if (!Number.isFinite(absValue) || absValue === 0) return 0
const magnitude = 10 ** Math.floor(Math.log10(absValue))
const step = magnitude >= 10 ? magnitude / 10 : magnitude
const precision = getAxisPrecision(step)
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
}
const formatAxisLabel = (value: number, precision: number) => {
if (!Number.isFinite(value)) return ''
return `${normalizeAxisValue(value, precision)}`
}
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
// 趋势图按当前可见点数调整线宽,避免大数据初始展示时线条过粗。
const resolveTrendVisiblePointCount = (pointCount: number) => {
const { start, end } = trendXZoomRange.value
const visibleRatio = Math.max((end - start) / 100, 0)
return Math.ceil(pointCount * visibleRatio)
}
const resolveTrendLineWidth = (pointCount: number) => {
if (pointCount >= 200000) return 0.35
if (pointCount >= 100000) return 0.45
if (pointCount >= 50000) return 0.55
if (pointCount >= 20000) return 0.65
if (pointCount >= 10000) return 0.75
if (pointCount >= 5000) return 0.9
if (pointCount >= 2000) return 1
if (pointCount >= 800) return 1.2
if (pointCount >= 200) return 1.4
return TREND_LINE_MAX_WIDTH
}
const resetTrendToolState = () => {
trendXZoomRange.value = { start: 0, end: 100 }
trendYZoomScale.value = 1
activeTrendInteractionMode.value = 'none'
}
const zoomTrendXAxis = (ratio: number) => {
const { start, end } = trendXZoomRange.value
const center = (start + end) / 2
const nextWidth = Math.min(Math.max((end - start) * ratio, 1), 100)
const nextStart = clampPercent(center - nextWidth / 2)
const nextEnd = clampPercent(center + nextWidth / 2)
if (nextStart === 0) {
trendXZoomRange.value = { start: 0, end: nextWidth }
return
}
if (nextEnd === 100) {
trendXZoomRange.value = { start: 100 - nextWidth, end: 100 }
return
}
trendXZoomRange.value = { start: nextStart, end: nextEnd }
}
const applyYAxisZoom = (yAxisConfig: Record<string, unknown>) => {
const axisMin = Number(yAxisConfig.min)
const axisMax = Number(yAxisConfig.max)
const scale = trendYZoomScale.value
if (!Number.isFinite(axisMin) || !Number.isFinite(axisMax) || axisMin === axisMax || scale === 1) {
return yAxisConfig
}
const center = (axisMin + axisMax) / 2
const halfRange = ((axisMax - axisMin) * scale) / 2
const nextMin = center - halfRange
const nextMax = center + halfRange
const splitNumber = Number(yAxisConfig.splitNumber) || TREND_AXIS_DEFAULT_SPLIT_COUNT
const interval = (nextMax - nextMin) / splitNumber
const precision = getAxisBoundaryPrecision(nextMin, nextMax, interval)
const normalizedInterval = normalizeAxisValue(interval, precision)
return {
...yAxisConfig,
min: normalizeAxisValue(nextMin, precision),
max: normalizeAxisValue(nextMax, precision),
interval: normalizedInterval,
minInterval: normalizedInterval,
maxInterval: normalizedInterval
}
}
const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount: number) => {
const axisRange = axisMax - axisMin
const rawInterval = axisRange / splitCount
if (!Number.isFinite(axisRange) || axisRange <= 0 || rawInterval >= TREND_AXIS_SMALL_INTERVAL_THRESHOLD) {
return {
axisMin,
axisMax,
interval: rawInterval,
splitCount
}
}
const splitCountCandidates =
splitCount >= TREND_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
return splitCountCandidates.reduce(
(currentBest, currentSplitCount) => {
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
let readableMin = axisMin
let readableMax = axisMax
for (let index = 0; index < TREND_AXIS_READABLE_INTERVAL_STEPS.length; index += 1) {
readableMin = Math.floor(axisMin / interval) * interval
readableMax = readableMin + interval * currentSplitCount
if (readableMax < axisMax) {
readableMax = Math.ceil(axisMax / interval) * interval
readableMin = readableMax - interval * currentSplitCount
}
if (readableMin <= axisMin && readableMax >= axisMax) break
interval = getReadableAxisInterval(interval * 1.01)
}
const precision = getAxisPrecision(interval)
const normalizedMin = normalizeAxisValue(readableMin, precision)
const normalizedMax = normalizeAxisValue(readableMax, precision)
const normalizedInterval = normalizeAxisValue(interval, precision)
const extraSplitCost = Math.max(currentSplitCount - splitCount, 0) * TREND_AXIS_EXTRA_SPLIT_SCORE
const wasteRatio = (normalizedMax - normalizedMin - axisRange) / axisRange
const score = getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
if (score >= currentBest.score) return currentBest
return {
axisMin: normalizedMin,
axisMax: normalizedMax,
interval: normalizedInterval,
splitCount: currentSplitCount,
score
}
},
{
axisMin,
axisMax,
interval: rawInterval,
splitCount,
score: Number.POSITIVE_INFINITY
}
)
}
const resolveExpandedAxisBoundary = (value: number, isMin: boolean) => {
if (value === 0) return 0
const ratio = isMin
? value < 0
? TREND_AXIS_EXPAND_RATIO
: TREND_AXIS_SHRINK_RATIO
: value > 0
? TREND_AXIS_EXPAND_RATIO
: TREND_AXIS_SHRINK_RATIO
return value * ratio
}
const resolveTrendAxisSplitCount = (layoutOptions: TrendChartLayoutOptions) => {
return layoutOptions.yAxisSplitCount || TREND_AXIS_DEFAULT_SPLIT_COUNT
}
const shouldUseBalancedAxisBoundary = (min: number, max: number) => {
if (min >= 0 || max <= 0) return false
const minAbs = Math.abs(min)
const maxAbs = Math.abs(max)
const smallerAbs = Math.min(minAbs, maxAbs)
const largerAbs = Math.max(minAbs, maxAbs)
return largerAbs > 0 && smallerAbs / largerAbs >= TREND_AXIS_BALANCED_RATIO
}
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = TREND_AXIS_DEFAULT_SPLIT_COUNT) => {
const min = Number(trendPayload.min)
const max = Number(trendPayload.max)
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return {
name: trendPayload.unit || ''
}
}
let axisMin = resolveExpandedAxisBoundary(min, true)
let axisMax = resolveExpandedAxisBoundary(max, false)
if (shouldUseBalancedAxisBoundary(min, max)) {
// 正负幅值接近时,对称边界必须基于已向外扩展后的范围,避免把峰值贴到坐标边界。
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(axisMin), Math.abs(axisMax)))
axisMin = -axisBoundary
axisMax = axisBoundary
}
if (axisMin === axisMax) {
const fallbackBoundary = Math.max(Math.abs(axisMin), 1) * TREND_AXIS_EXPAND_RATIO
axisMin = axisMin - fallbackBoundary
axisMax = axisMax + fallbackBoundary
}
const safeSplitCount = Math.max(Math.round(splitCount), 1)
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, safeSplitCount)
const precision = getAxisBoundaryPrecision(
readableAxisRange.axisMin,
readableAxisRange.axisMax,
readableAxisRange.interval
)
axisMin = normalizeAxisValue(readableAxisRange.axisMin, precision)
axisMax = normalizeAxisValue(readableAxisRange.axisMax, precision)
const interval = normalizeAxisValue(readableAxisRange.interval, precision)
const axisSplitCount = readableAxisRange.splitCount
return {
name: trendPayload.unit || '',
nameLocation: 'middle',
nameGap: 42,
nameTextStyle: {
color: axisTextColor,
fontSize: 12,
align: 'center'
},
min: axisMin,
max: axisMax,
interval,
splitNumber: axisSplitCount,
minInterval: interval,
maxInterval: interval,
// 纵坐标按数据极值留白后均分,小区间优先使用可读步长,避免标签出现冗长小数。
axisLabel: {
showMinLabel: true,
showMaxLabel: true,
hideOverlap: true,
formatter: (value: number) => formatAxisLabel(value, precision)
}
}
}
const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
return seriesList.map(item => {
const visiblePointCount = resolveTrendVisiblePointCount(item.data.length)
return {
name: item.name,
type: 'line',
smooth: true,
symbol: 'none',
symbolSize: 3,
lineStyle: {
width: resolveTrendLineWidth(visiblePointCount)
},
data: item.data
}
})
}
const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1
return (value: string | number, index: number) => {
// 横坐标只保留首尾标签,避免波形点位过多时底部文字拥挤。
if (index !== 0 && index !== lastIndex) return ''
return formatNumber(value, 2)
}
}
interface TrendTooltipParam {
axisValue?: string | number
marker?: string
seriesName?: string
value?: number | string
}
const buildTrendTooltipFormatter = (unit: string, showTime = true) => {
return (params: TrendTooltipParam | TrendTooltipParam[]) => {
const paramList = Array.isArray(params) ? params : [params]
const firstParam = paramList[0]
const timeValue = firstParam?.axisValue
const valueRows = paramList
.map(item => {
const marker = item.marker || ''
const seriesName = item.seriesName || ''
const valueText = unit ? `${formatNumber(item.value)} ${unit}` : formatNumber(item.value)
return `<div>${marker}${seriesName}<span style="float:right;margin-left:12px;font-weight:600;">${valueText}</span></div>`
})
.join('')
const timeText = timeValue === undefined ? '--' : `${formatNumber(timeValue, 2)} ms`
if (!showTime) return valueRows
return `${valueRows}<div style="margin-top:4px;">时间<span style="float:right;margin-left:12px;">${timeText}</span></div>`
}
}
const buildTrendChartOptions = (
trendPayload: WaveformTrendPayload,
seriesList: WaveformSeriesItem[],
chartColors = currentTrendColors.value,
layoutOptions: TrendChartLayoutOptions = {}
) => {
const { showTimeAxis = true } = layoutOptions
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
return {
activeTool: activeTrendInteractionMode.value,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
snap: true,
lineStyle: {
color: 'rgba(24, 144, 255, 0.55)',
width: 1
}
},
formatter: buildTrendTooltipFormatter(trendPayload.unit, showTimeAxis)
},
legend: {
show: false,
top: 0,
right: 12
},
grid: {
top: TREND_GRID_TOP,
// 多图趋势图固定绘图区左边界,避免纵坐标标签宽度不同导致 x=0 起点错位。
left: TREND_GRID_LEFT,
right: TREND_GRID_RIGHT,
bottom: showTimeAxis ? TREND_GRID_BOTTOM.withTimeAxis : TREND_GRID_BOTTOM.withoutTimeAxis,
containLabel: false
},
xAxis: {
data: trendPayload.timeLabels,
boundaryGap: false,
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
name: showTimeAxis ? 'ms' : '',
nameLocation: 'end',
nameGap: showTimeAxis ? 8 : 0,
nameTextStyle: {
color: axisTextColor
},
axisLine: {
show: showTimeAxis,
// 时间轴固定在图底部,避免 y 轴包含 0 时横轴穿过波形区域显得过粗。
onZero: false,
lineStyle: {
color: axisLineColor
}
},
axisLabel: {
show: showTimeAxis,
hideOverlap: false,
interval: 0,
margin: showTimeAxis ? 10 : 0,
color: axisTextColor,
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
},
axisTick: {
show: showTimeAxis,
lineStyle: {
color: axisLineColor
}
}
},
yAxis: yAxisConfig,
dataZoom: [
{
type: 'inside',
start: trendXZoomRange.value.start,
end: trendXZoomRange.value.end,
zoomOnMouseWheel: activeTrendInteractionMode.value !== 'pan',
moveOnMouseMove: activeTrendInteractionMode.value === 'pan',
moveOnMouseWheel: activeTrendInteractionMode.value === 'pan'
}
],
toolbox: {
// 外部工具栏通过 takeGlobalCursor 激活框选放大ECharts 仍需要注册 toolbox.dataZoom 才会创建框选控制器。
show: true,
showTitle: false,
itemSize: 0,
itemGap: 0,
left: -100,
top: -100,
feature: {
dataZoom: {
yAxisIndex: 'none',
brushStyle: {
color: 'rgba(64, 158, 255, 0.18)',
borderColor: 'rgba(64, 158, 255, 0.65)',
borderWidth: 1
}
}
}
},
color: chartColors,
series: buildTrendSeries(seriesList)
}
}
const activeTrendOptions = computed<Record<string, unknown>>(() => {
const trendPayload = activeTrendPayload.value
return buildTrendChartOptions(trendPayload, trendPayload.series)
})
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
const buildSingleChannelTrendOptionsList = (
detail: Waveform.WaveDataDetail | null,
detailIndex: number,
chartGroup: string,
resolveShowTimeAxis = (seriesIndex: number, seriesCount: number) => seriesIndex === seriesCount - 1
): SingleChannelTrendOption[] => {
const trendPayload = buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
const trendColors = getTrendColors(detail)
// 单通道下每张图只保留一个 series需要单独指定对应相色。
return trendPayload.series.map((item, index) => {
const showTimeAxis = resolveShowTimeAxis(index, trendPayload.series.length)
const itemTrendPayload = {
...trendPayload,
min: item.data.length ? Math.min(...item.data) : undefined,
max: item.data.length ? Math.max(...item.data) : undefined,
series: [item]
}
return {
key: `${detailIndex}-${item.name}`,
group: chartGroup,
isLastChart: showTimeAxis,
options: buildTrendChartOptions(
itemTrendPayload,
itemTrendPayload.series,
[trendColors[index] || defaultPhaseColors[index]],
{
showTimeAxis,
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
}
)
}
})
}
const singleChannelTrendOptionsList = computed<SingleChannelTrendOption[]>(() => {
return buildSingleChannelTrendOptionsList(activeWaveDetail.value, -1, singleChannelTrendChartGroup)
})
const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
const availableDetails = normalizedWaveDetails.value
.map((detail, index) => {
const trendPayload = buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
return {
detail,
index,
trendPayload
}
})
.filter(item => item.trendPayload.series.length > 0)
return availableDetails.map((item, groupIndex) => {
const isLastGroup = groupIndex === availableDetails.length - 1
return {
key: `${item.index}-${buildChannelLabel(item.detail, item.index)}`,
title: buildChannelLabel(item.detail, item.index),
group: allChannelTrendChartGroup,
isLastChart: isLastGroup,
singleChannelOptionsList: buildSingleChannelTrendOptionsList(
item.detail,
item.index,
allChannelTrendChartGroup,
(seriesIndex, seriesCount) => isLastGroup && seriesIndex === seriesCount - 1
),
multiChannelOptions: buildTrendChartOptions(
item.trendPayload,
item.trendPayload.series,
getTrendColors(item.detail),
{
showTimeAxis: isLastGroup,
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
}
)
}
})
})
const summaryItems = computed<SummaryItem[]>(() => {
const waveData = activeWaveData.value
const cfgData = waveData?.comtradeCfgDTO
const detail = activeWaveDetail.value
return [
{ label: '录波开始', value: formatWaveformTime(cfgData?.timeStart) },
{ label: '触发时间', value: formatWaveformTime(cfgData?.timeTrige) },
{ label: '采样率', value: cfgData?.finalSampleRate ? `${cfgData.finalSampleRate} Hz` : '--' },
{ label: '总通道数', value: cfgData?.nChannelNum ?? '--' },
{
label: '当前通道',
value: isAllChannelsActive.value
? '全部'
: detail
? buildChannelLabel(detail, activeChannelIndex.value as number)
: '--'
},
{ 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] }
]
})
const handleTrendToolAction = async (action: TrendToolAction) => {
if (!hasWaveformData.value) return
switch (action) {
case 'x-zoom-in':
zoomTrendXAxis(0.8)
break
case 'x-zoom-out':
zoomTrendXAxis(1.25)
break
case 'y-zoom-in':
trendYZoomScale.value = Math.max(trendYZoomScale.value * 0.8, 0.1)
break
case 'y-zoom-out':
trendYZoomScale.value = Math.min(trendYZoomScale.value * 1.25, 10)
break
case 'box-zoom':
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
break
case 'pan':
if (!canPanTrendChart.value) {
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
activeTrendInteractionMode.value = 'none'
break
}
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
break
case 'reset':
resetTrendToolState()
break
case 'download-image':
await downloadTrendImage()
break
case 'download-data':
downloadTrendData()
break
default:
break
}
}
const handleTrendChartDataZoom = (value: TrendChartZoomPayload) => {
trendXZoomRange.value = {
start: clampPercent(value.start),
end: clampPercent(value.end)
}
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
activeTrendInteractionMode.value = 'none'
}
}
watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {
resetTrendToolState()
})
const getFileBaseName = (fileName: string) => {
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
}
const resetSelectedWaveformFiles = () => {
selectedCfgFile.value = null
selectedDatFile.value = null
waveformParseResult.value = null
vectorParseResult.value = null
lastParseErrorMessage.value = ''
lastVectorParseErrorMessage.value = ''
}
const handleWaveformFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const fileList = Array.from(input.files || [])
if (!fileList.length) return
const cfgFiles = fileList.filter(item => item.name.toLowerCase().endsWith('.cfg'))
const datFiles = fileList.filter(item => item.name.toLowerCase().endsWith('.dat'))
if (cfgFiles.length !== 1 || datFiles.length !== 1 || fileList.length !== 2) {
resetSelectedWaveformFiles()
ElMessage.warning('请选择一组同名的.cfg和.dat文件')
return
}
const [cfgFile] = cfgFiles
const [datFile] = datFiles
if (!cfgFile || !datFile) {
resetSelectedWaveformFiles()
ElMessage.warning('请选择同一组.cfg和.dat文件')
return
}
if (getFileBaseName(cfgFile.name) !== getFileBaseName(datFile.name)) {
resetSelectedWaveformFiles()
ElMessage.warning('请选择同名的.cfg和.dat文件')
return
}
selectedCfgFile.value = cfgFile
selectedDatFile.value = datFile
await loadWaveformData(cfgFile, datFile)
}
const loadWaveformData = async (cfgFile: File, datFile: File) => {
try {
isParsing.value = true
activeChannelIndex.value = 'all'
resetTrendToolState()
lastParseErrorMessage.value = ''
lastVectorParseErrorMessage.value = ''
// 波形与向量结果共用同一组文件,这里并行请求以缩短右侧联动展示等待时间。
const [waveformResult, vectorResult] = await Promise.allSettled([
parseComtradeApi({
cfgFile,
datFile
}),
parseComtradeVectorApi({
cfgFile,
datFile,
parseType: 3
})
])
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: 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
}
}
const buildTrendExportFileName = (extension: string) => {
const channelLabel = isAllChannelsActive.value
? '全部'
: activeWaveDetail.value
? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value as number)
: '波形'
return `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.${extension}`
}
const downloadTrendImage = async () => {
await nextTick()
const targetElement = document.querySelector('.waveform-trend-export-target') as HTMLElement | null
if (!targetElement) {
ElMessage.warning('未找到可导出的趋势图区域')
return
}
const canvas = await html2canvas(targetElement, {
backgroundColor: '#ffffff',
scale: window.devicePixelRatio || 1,
useCORS: true
})
const imageUrl = canvas.toDataURL('image/png')
const exportFile = document.createElement('a')
exportFile.style.display = 'none'
exportFile.download = buildTrendExportFileName('png')
exportFile.href = imageUrl
document.body.appendChild(exportFile)
exportFile.click()
document.body.removeChild(exportFile)
ElMessage.success('趋势图图片下载成功')
}
const downloadTrendData = () => {
if (!hasWaveformData.value) {
ElMessage.warning('暂无可导出的波形数据')
return
}
const trendPayload = activeTrendPayload.value
const allChannelPayloads = normalizedWaveDetails.value
.map((detail, index) => ({
label: buildChannelLabel(detail, index),
payload: buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
}))
.filter(item => item.payload.series.length > 0)
const exportPayload = isAllChannelsActive.value ? allChannelPayloads[0]?.payload : trendPayload
if (!exportPayload) {
ElMessage.warning('暂无可导出的波形数据')
return
}
const header = ['时间', ...trendPayload.series.map(item => item.name)]
const allChannelHeader = [
'时间',
...allChannelPayloads.flatMap(item => item.payload.series.map(series => `${item.label}-${series.name}`))
]
const rows = exportPayload.timeLabels.map((time, index) => {
if (isAllChannelsActive.value) {
return [
time,
...allChannelPayloads.flatMap(item => item.payload.series.map(series => series.data[index] ?? ''))
]
}
return [time, ...trendPayload.series.map(item => item.data[index] ?? '')]
})
const csvContent = [isAllChannelsActive.value ? allChannelHeader : header, ...rows]
.map(row => row.join(','))
.join('\n')
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
const blobUrl = URL.createObjectURL(blob)
const exportFile = document.createElement('a')
const fileName = buildTrendExportFileName('csv')
exportFile.style.display = 'none'
exportFile.download = fileName
exportFile.href = blobUrl
document.body.appendChild(exportFile)
exportFile.click()
document.body.removeChild(exportFile)
URL.revokeObjectURL(blobUrl)
ElMessage.success('趋势图数据下载成功')
}
</script>
<style scoped lang="scss">
.waveform-page {
gap: 12px;
overflow: hidden;
}
.waveform-layout {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.72fr);
gap: 16px;
width: 100%;
height: 100%;
overflow: hidden;
}
@media (max-width: 1280px) {
.waveform-layout {
grid-template-columns: 1fr;
}
}
</style>