2026-05-06 16:35:48 +08:00
|
|
|
|
<template>
|
2026-04-16 08:15:46 +08:00
|
|
|
|
<div class="table-box waveform-page">
|
2026-04-16 20:20:52 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="waveform-layout">
|
2026-04-16 20:20:52 +08:00
|
|
|
|
<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"
|
2026-05-06 16:35:48 +08:00
|
|
|
|
:all-channel-trend-groups="allChannelTrendGroups"
|
|
|
|
|
|
:is-all-channels-active="isAllChannelsActive"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
:last-parse-error-message="lastParseErrorMessage"
|
2026-05-07 09:38:06 +08:00
|
|
|
|
:active-trend-tool-states="activeTrendToolStates"
|
2026-05-07 11:16:51 +08:00
|
|
|
|
:disabled-trend-tool-states="disabledTrendToolStates"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
@update:active-trend-tab="activeTrendTab = $event"
|
2026-05-07 09:38:06 +08:00
|
|
|
|
@trend-tool-action="handleTrendToolAction"
|
2026-05-07 11:16:51 +08:00
|
|
|
|
@chart-data-zoom="handleTrendChartDataZoom"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<WaveformInfoPanel
|
|
|
|
|
|
:has-parsed-waveform="hasParsedWaveform"
|
|
|
|
|
|
:summary-items="summaryItems"
|
2026-04-17 08:10:46 +08:00
|
|
|
|
:vector-parse-result="vectorParseResult"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
:last-parse-error-message="lastParseErrorMessage"
|
2026-04-17 08:10:46 +08:00
|
|
|
|
:last-vector-parse-error-message="lastVectorParseErrorMessage"
|
|
|
|
|
|
:active-vector-channel-name="activeWaveDetail?.channelName || ''"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
/>
|
2026-04-16 08:15:46 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-07 09:38:06 +08:00
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
2026-04-17 08:10:46 +08:00
|
|
|
|
import dayjs from 'dayjs'
|
2026-05-07 09:38:06 +08:00
|
|
|
|
import html2canvas from 'html2canvas'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-04-17 08:10:46 +08:00
|
|
|
|
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
import type { Waveform } from '@/api/tools/waveform/interface'
|
2026-04-16 20:20:52 +08:00
|
|
|
|
import WaveformInfoPanel from './components/WaveformInfoPanel.vue'
|
|
|
|
|
|
import WaveformToolbar from './components/WaveformToolbar.vue'
|
|
|
|
|
|
import WaveformTrendPanel from './components/WaveformTrendPanel.vue'
|
|
|
|
|
|
import type {
|
2026-05-06 16:35:48 +08:00
|
|
|
|
AllChannelTrendGroup,
|
|
|
|
|
|
ChannelSelectValue,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
DisplayMode,
|
|
|
|
|
|
LabelValueOption,
|
|
|
|
|
|
SingleChannelTrendOption,
|
|
|
|
|
|
SummaryItem,
|
2026-05-07 11:16:51 +08:00
|
|
|
|
TrendChartZoomPayload,
|
2026-05-07 09:38:06 +08:00
|
|
|
|
TrendToolAction,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
TrendTabValue,
|
|
|
|
|
|
ValueMode,
|
|
|
|
|
|
WaveformDetailOption
|
|
|
|
|
|
} from './components/types'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-07 09:38:06 +08:00
|
|
|
|
yAxisSplitCount?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface TrendZoomRange {
|
|
|
|
|
|
start: number
|
|
|
|
|
|
end: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const activeTrendTab = ref<TrendTabValue>('instant')
|
|
|
|
|
|
const activeValueMode = ref<ValueMode>('primary')
|
|
|
|
|
|
const activeDisplayMode = ref<DisplayMode>('single-channel')
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const activeChannelIndex = ref<ChannelSelectValue>('all')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const allChannelTrendChartGroup = 'waveform-all-channel-sync'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const isParsing = ref(false)
|
|
|
|
|
|
const selectedCfgFile = ref<File | null>(null)
|
|
|
|
|
|
const selectedDatFile = ref<File | null>(null)
|
|
|
|
|
|
const waveformParseResult = ref<Waveform.WaveComtradeResultVO | null>(null)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const vectorParseResult = ref<Waveform.WaveComtradeVectorResultVO | null>(null)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const lastParseErrorMessage = ref('')
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const lastVectorParseErrorMessage = ref('')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const waveformFileAccept = '.cfg,.dat'
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
|
|
|
|
|
|
const trendYZoomScale = ref(1)
|
|
|
|
|
|
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan'>('none')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const trendTabs: LabelValueOption<TrendTabValue>[] = [
|
|
|
|
|
|
{ value: 'instant', label: '瞬时波形' },
|
|
|
|
|
|
{ value: 'rms', label: 'RMS 波形' }
|
2026-04-16 08:15:46 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const valueModeOptions: LabelValueOption<ValueMode>[] = [
|
2026-04-16 08:15:46 +08:00
|
|
|
|
{ label: '一次值', value: 'primary' },
|
|
|
|
|
|
{ label: '二次值', value: 'secondary' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const displayModeOptions: LabelValueOption<DisplayMode>[] = [
|
|
|
|
|
|
{ label: '单通道', value: 'single-channel' },
|
|
|
|
|
|
{ label: '多通道', value: 'multi-channel' }
|
2026-04-16 08:15:46 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const trendLabelMap: Record<TrendTabValue, string> = {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
instant: '瞬时波形',
|
|
|
|
|
|
rms: 'RMS 波形'
|
2026-04-16 20:20:52 +08:00
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const valueModeLabelMap: Record<ValueMode, string> = {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
primary: '一次值',
|
|
|
|
|
|
secondary: '二次值'
|
2026-04-16 20:20:52 +08:00
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
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]
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
|
|
|
|
|
|
const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
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[]>(() => {
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const detailOptions = normalizedWaveDetails.value.map((item, index) => ({
|
2026-04-16 08:15:46 +08:00
|
|
|
|
label: buildChannelLabel(item, index),
|
|
|
|
|
|
value: index
|
|
|
|
|
|
}))
|
2026-05-06 16:35:48 +08:00
|
|
|
|
|
|
|
|
|
|
return detailOptions.length ? [{ label: '全部', value: 'all' }, ...detailOptions] : []
|
2026-04-16 08:15:46 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const isAllChannelsActive = computed(() => activeChannelIndex.value === 'all')
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const activeWaveDetail = computed(() => {
|
2026-05-06 16:35:48 +08:00
|
|
|
|
if (typeof activeChannelIndex.value !== 'number') return null
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const getValueScale = (detail: Waveform.WaveDataDetail | null) => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
if (activeValueMode.value === 'secondary') return 1
|
|
|
|
|
|
|
|
|
|
|
|
const waveData = activeWaveData.value
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const ratio = isCurrentChannel(detail?.channelName) ? waveData?.ct : waveData?.pt
|
2026-04-16 08:15:46 +08:00
|
|
|
|
return normalizeRatio(ratio)
|
2026-05-06 16:35:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const activeValueScale = computed(() => getValueScale(activeWaveDetail.value))
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
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))}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const formatWaveformTime = (value?: string) => {
|
|
|
|
|
|
if (!value) return '--'
|
|
|
|
|
|
|
|
|
|
|
|
const parsedValue = dayjs(value)
|
|
|
|
|
|
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const buildTrendPayload = (
|
|
|
|
|
|
detail: Waveform.WaveDataDetail | null,
|
|
|
|
|
|
trendTab: TrendTabValue,
|
|
|
|
|
|
scale: number
|
|
|
|
|
|
): WaveformTrendPayload => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const getTrendColors = (detail: Waveform.WaveDataDetail | null) => {
|
|
|
|
|
|
const customColors = detail?.colors?.filter(Boolean) || []
|
2026-04-16 08:15:46 +08:00
|
|
|
|
return customColors.length >= 3 ? customColors.slice(0, 3) : defaultPhaseColors
|
2026-05-06 16:35:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentTrendColors = computed(() => getTrendColors(activeWaveDetail.value))
|
|
|
|
|
|
|
|
|
|
|
|
const hasWaveformData = computed(() => {
|
|
|
|
|
|
if (isAllChannelsActive.value) return allChannelTrendGroups.value.length > 0
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
return activeTrendPayload.value.series.length > 0
|
|
|
|
|
|
})
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-05-07 11:16:51 +08:00
|
|
|
|
const canPanTrendChart = computed(() => {
|
|
|
|
|
|
const { start, end } = trendXZoomRange.value
|
|
|
|
|
|
|
|
|
|
|
|
return hasWaveformData.value && (start > 0 || end < 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 11:47:44 +08:00
|
|
|
|
const canResetTrendChart = computed(() => {
|
|
|
|
|
|
const { start, end } = trendXZoomRange.value
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
hasWaveformData.value &&
|
|
|
|
|
|
(start > 0 || end < 100 || trendYZoomScale.value !== 1 || activeTrendInteractionMode.value !== 'none')
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
|
|
|
|
|
|
'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
|
2026-05-07 11:16:51 +08:00
|
|
|
|
pan: activeTrendInteractionMode.value === 'pan'
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const disabledTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
|
2026-05-07 11:47:44 +08:00
|
|
|
|
pan: !canPanTrendChart.value,
|
|
|
|
|
|
reset: !canResetTrendChart.value
|
2026-05-07 09:38:06 +08:00
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
2026-05-07 11:47:44 +08:00
|
|
|
|
const TREND_GRID_RIGHT = '36px'
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const TREND_GRID_BOTTOM = {
|
2026-05-07 11:47:44 +08:00
|
|
|
|
withTimeAxis: '34px',
|
2026-05-07 09:38:06 +08:00
|
|
|
|
withoutTimeAxis: '6px'
|
|
|
|
|
|
}
|
2026-05-07 11:16:51 +08:00
|
|
|
|
const TREND_LINE_MAX_WIDTH = 1.6
|
2026-04-17 08:10:46 +08:00
|
|
|
|
|
|
|
|
|
|
const getAxisPrecision = (step: number) => {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const absStep = Math.abs(step)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
if (!Number.isFinite(absStep) || absStep >= 1) return 0
|
|
|
|
|
|
|
|
|
|
|
|
const stepText = `${absStep}`
|
2026-04-17 08:10:46 +08:00
|
|
|
|
if (stepText.includes('e-')) {
|
|
|
|
|
|
return Number(stepText.split('e-')[1] || 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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
|
2026-04-17 08:10:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeAxisValue = (value: number, precision: number) => {
|
|
|
|
|
|
const factor = 10 ** precision
|
|
|
|
|
|
const normalizedValue = Math.round(value * factor) / factor
|
|
|
|
|
|
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const roundAxisValueUp = (value: number) => {
|
|
|
|
|
|
const absValue = Math.abs(value)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
if (!Number.isFinite(absValue) || absValue === 0) return 0
|
2026-04-17 08:10:46 +08:00
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const magnitude = 10 ** Math.floor(Math.log10(absValue))
|
|
|
|
|
|
const step = magnitude >= 10 ? magnitude / 10 : magnitude
|
|
|
|
|
|
const precision = getAxisPrecision(step)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatAxisLabel = (value: number, precision: number) => {
|
|
|
|
|
|
if (!Number.isFinite(value)) return ''
|
|
|
|
|
|
return `${normalizeAxisValue(value, precision)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
|
|
|
|
|
|
2026-05-07 11:16:51 +08:00
|
|
|
|
// 趋势图按当前可见点数调整线宽,避免大数据初始展示时线条过粗。
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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) => {
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const min = Number(trendPayload.min)
|
|
|
|
|
|
const max = Number(trendPayload.max)
|
|
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: trendPayload.unit || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
let axisMin = resolveExpandedAxisBoundary(min, true)
|
|
|
|
|
|
let axisMax = resolveExpandedAxisBoundary(max, false)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
if (shouldUseBalancedAxisBoundary(min, max)) {
|
2026-05-07 11:16:51 +08:00
|
|
|
|
// 正负幅值接近时,对称边界必须基于已向外扩展后的范围,避免把峰值贴到坐标边界。
|
|
|
|
|
|
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(axisMin), Math.abs(axisMax)))
|
2026-05-07 09:38:06 +08:00
|
|
|
|
axisMin = -axisBoundary
|
|
|
|
|
|
axisMax = axisBoundary
|
2026-04-17 08:10:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (axisMin === axisMax) {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const fallbackBoundary = Math.max(Math.abs(axisMin), 1) * TREND_AXIS_EXPAND_RATIO
|
|
|
|
|
|
axisMin = axisMin - fallbackBoundary
|
|
|
|
|
|
axisMax = axisMax + fallbackBoundary
|
2026-04-17 08:10:46 +08:00
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
return {
|
|
|
|
|
|
name: trendPayload.unit || '',
|
2026-05-07 09:38:06 +08:00
|
|
|
|
nameLocation: 'middle',
|
|
|
|
|
|
nameGap: 42,
|
|
|
|
|
|
nameTextStyle: {
|
|
|
|
|
|
color: axisTextColor,
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
align: 'center'
|
|
|
|
|
|
},
|
2026-04-17 08:10:46 +08:00
|
|
|
|
min: axisMin,
|
|
|
|
|
|
max: axisMax,
|
|
|
|
|
|
interval,
|
2026-05-07 09:38:06 +08:00
|
|
|
|
splitNumber: axisSplitCount,
|
2026-04-17 08:10:46 +08:00
|
|
|
|
minInterval: interval,
|
|
|
|
|
|
maxInterval: interval,
|
2026-05-07 09:38:06 +08:00
|
|
|
|
// 纵坐标按数据极值留白后均分,小区间优先使用可读步长,避免标签出现冗长小数。
|
2026-04-17 08:10:46 +08:00
|
|
|
|
axisLabel: {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
showMinLabel: true,
|
|
|
|
|
|
showMaxLabel: true,
|
2026-04-17 08:10:46 +08:00
|
|
|
|
hideOverlap: true,
|
|
|
|
|
|
formatter: (value: number) => formatAxisLabel(value, precision)
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-07 09:38:06 +08:00
|
|
|
|
|
2026-05-07 11:16:51 +08:00
|
|
|
|
const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
|
|
|
|
|
|
return seriesList.map(item => {
|
|
|
|
|
|
const visiblePointCount = resolveTrendVisiblePointCount(item.data.length)
|
2026-05-07 09:38:06 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: item.name,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
2026-05-07 11:16:51 +08:00
|
|
|
|
symbol: 'none',
|
2026-05-07 09:38:06 +08:00
|
|
|
|
symbolSize: 3,
|
2026-05-07 11:16:51 +08:00
|
|
|
|
lineStyle: {
|
|
|
|
|
|
width: resolveTrendLineWidth(visiblePointCount)
|
|
|
|
|
|
},
|
|
|
|
|
|
data: item.data
|
2026-05-07 09:38:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
interface TrendTooltipParam {
|
|
|
|
|
|
axisValue?: string | number
|
|
|
|
|
|
marker?: string
|
|
|
|
|
|
seriesName?: string
|
|
|
|
|
|
value?: number | string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const buildTrendTooltipFormatter = (unit: string, showTime = true) => {
|
2026-05-06 16:35:48 +08:00
|
|
|
|
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`
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
if (!showTime) return valueRows
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
return `${valueRows}<div style="margin-top:4px;">时间<span style="float:right;margin-left:12px;">${timeText}</span></div>`
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const buildTrendChartOptions = (
|
|
|
|
|
|
trendPayload: WaveformTrendPayload,
|
|
|
|
|
|
seriesList: WaveformSeriesItem[],
|
|
|
|
|
|
chartColors = currentTrendColors.value,
|
|
|
|
|
|
layoutOptions: TrendChartLayoutOptions = {}
|
|
|
|
|
|
) => {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const { showTimeAxis = true } = layoutOptions
|
|
|
|
|
|
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
|
|
|
|
|
|
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
activeTool: activeTrendInteractionMode.value,
|
2026-04-16 08:15:46 +08:00
|
|
|
|
tooltip: {
|
2026-04-16 20:20:52 +08:00
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
axisPointer: {
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
snap: true,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: 'rgba(24, 144, 255, 0.55)',
|
|
|
|
|
|
width: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-07 09:38:06 +08:00
|
|
|
|
formatter: buildTrendTooltipFormatter(trendPayload.unit, showTimeAxis)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
show: false,
|
2026-04-17 08:10:46 +08:00
|
|
|
|
top: 0,
|
2026-04-16 08:15:46 +08:00
|
|
|
|
right: 12
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: {
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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
|
2026-04-16 08:15:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
data: trendPayload.timeLabels,
|
|
|
|
|
|
boundaryGap: false,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
|
2026-04-16 08:15:46 +08:00
|
|
|
|
name: showTimeAxis ? 'ms' : '',
|
|
|
|
|
|
nameLocation: 'end',
|
2026-05-07 11:47:44 +08:00
|
|
|
|
nameGap: showTimeAxis ? 8 : 0,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
nameTextStyle: {
|
|
|
|
|
|
color: axisTextColor
|
|
|
|
|
|
},
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
show: showTimeAxis,
|
2026-04-17 08:10:46 +08:00
|
|
|
|
// 时间轴固定在图底部,避免 y 轴包含 0 时横轴穿过波形区域显得过粗。
|
|
|
|
|
|
onZero: false,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: axisLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-16 08:15:46 +08:00
|
|
|
|
axisLabel: {
|
|
|
|
|
|
show: showTimeAxis,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
hideOverlap: false,
|
|
|
|
|
|
interval: 0,
|
2026-05-07 09:38:06 +08:00
|
|
|
|
margin: showTimeAxis ? 10 : 0,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
color: axisTextColor,
|
|
|
|
|
|
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
axisTick: {
|
2026-04-16 20:20:52 +08:00
|
|
|
|
show: showTimeAxis,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: axisLineColor
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
2026-05-07 11:47:44 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-16 08:15:46 +08:00
|
|
|
|
color: chartColors,
|
2026-05-07 11:16:51 +08:00
|
|
|
|
series: buildTrendSeries(seriesList)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const activeTrendOptions = computed<Record<string, unknown>>(() => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const trendPayload = activeTrendPayload.value
|
|
|
|
|
|
return buildTrendChartOptions(trendPayload, trendPayload.series)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
|
2026-05-06 16:35:48 +08:00
|
|
|
|
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)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
// 单通道下每张图只保留一个 series,需要单独指定对应相色。
|
2026-05-06 16:35:48 +08:00
|
|
|
|
return trendPayload.series.map((item, index) => {
|
|
|
|
|
|
const showTimeAxis = resolveShowTimeAxis(index, trendPayload.series.length)
|
2026-05-07 11:16:51 +08:00
|
|
|
|
const itemTrendPayload = {
|
|
|
|
|
|
...trendPayload,
|
|
|
|
|
|
min: item.data.length ? Math.min(...item.data) : undefined,
|
|
|
|
|
|
max: item.data.length ? Math.max(...item.data) : undefined,
|
|
|
|
|
|
series: [item]
|
|
|
|
|
|
}
|
2026-05-06 16:35:48 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: `${detailIndex}-${item.name}`,
|
|
|
|
|
|
group: chartGroup,
|
|
|
|
|
|
isLastChart: showTimeAxis,
|
2026-05-07 11:16:51 +08:00
|
|
|
|
options: buildTrendChartOptions(
|
|
|
|
|
|
itemTrendPayload,
|
|
|
|
|
|
itemTrendPayload.series,
|
|
|
|
|
|
[trendColors[index] || defaultPhaseColors[index]],
|
|
|
|
|
|
{
|
|
|
|
|
|
showTimeAxis,
|
|
|
|
|
|
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-05-06 16:35:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
2026-05-06 16:35:48 +08:00
|
|
|
|
})
|
|
|
|
|
|
.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),
|
2026-05-07 09:38:06 +08:00
|
|
|
|
group: allChannelTrendChartGroup,
|
|
|
|
|
|
isLastChart: isLastGroup,
|
2026-05-06 16:35:48 +08:00
|
|
|
|
singleChannelOptionsList: buildSingleChannelTrendOptionsList(
|
|
|
|
|
|
item.detail,
|
|
|
|
|
|
item.index,
|
|
|
|
|
|
allChannelTrendChartGroup,
|
|
|
|
|
|
(seriesIndex, seriesCount) => isLastGroup && seriesIndex === seriesCount - 1
|
|
|
|
|
|
),
|
|
|
|
|
|
multiChannelOptions: buildTrendChartOptions(
|
|
|
|
|
|
item.trendPayload,
|
|
|
|
|
|
item.trendPayload.series,
|
2026-05-07 09:38:06 +08:00
|
|
|
|
getTrendColors(item.detail),
|
|
|
|
|
|
{
|
|
|
|
|
|
showTimeAxis: isLastGroup,
|
|
|
|
|
|
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
|
|
|
|
|
|
}
|
2026-05-06 16:35:48 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-16 08:15:46 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const summaryItems = computed<SummaryItem[]>(() => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const waveData = activeWaveData.value
|
|
|
|
|
|
const cfgData = waveData?.comtradeCfgDTO
|
|
|
|
|
|
const detail = activeWaveDetail.value
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
2026-04-17 08:10:46 +08:00
|
|
|
|
{ label: '录波开始', value: formatWaveformTime(cfgData?.timeStart) },
|
|
|
|
|
|
{ label: '触发时间', value: formatWaveformTime(cfgData?.timeTrige) },
|
2026-04-16 08:15:46 +08:00
|
|
|
|
{ label: '采样率', value: cfgData?.finalSampleRate ? `${cfgData.finalSampleRate} Hz` : '--' },
|
|
|
|
|
|
{ label: '总通道数', value: cfgData?.nChannelNum ?? '--' },
|
2026-05-06 16:35:48 +08:00
|
|
|
|
{
|
|
|
|
|
|
label: '当前通道',
|
|
|
|
|
|
value: isAllChannelsActive.value
|
|
|
|
|
|
? '全部'
|
|
|
|
|
|
: detail
|
|
|
|
|
|
? buildChannelLabel(detail, activeChannelIndex.value as number)
|
|
|
|
|
|
: '--'
|
|
|
|
|
|
},
|
2026-04-16 08:15:46 +08:00
|
|
|
|
{ label: '单位', value: detail?.unit || '--' },
|
|
|
|
|
|
{ label: '相别数量', value: waveData?.iPhasic ?? '--' },
|
|
|
|
|
|
{ label: 'PT / CT', value: `${formatNumber(waveData?.pt, 2)} / ${formatNumber(waveData?.ct, 2)}` },
|
2026-04-17 08:10:46 +08:00
|
|
|
|
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] }
|
2026-04-16 08:15:46 +08:00
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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':
|
2026-05-07 11:16:51 +08:00
|
|
|
|
if (!canPanTrendChart.value) {
|
|
|
|
|
|
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
|
|
|
|
|
activeTrendInteractionMode.value = 'none'
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 11:16:51 +08:00
|
|
|
|
const handleTrendChartDataZoom = (value: TrendChartZoomPayload) => {
|
|
|
|
|
|
trendXZoomRange.value = {
|
|
|
|
|
|
start: clampPercent(value.start),
|
|
|
|
|
|
end: clampPercent(value.end)
|
2026-05-07 09:38:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 11:16:51 +08:00
|
|
|
|
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
|
|
|
|
|
|
activeTrendInteractionMode.value = 'none'
|
|
|
|
|
|
}
|
2026-05-07 09:38:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {
|
|
|
|
|
|
resetTrendToolState()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const getFileBaseName = (fileName: string) => {
|
|
|
|
|
|
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resetSelectedWaveformFiles = () => {
|
|
|
|
|
|
selectedCfgFile.value = null
|
|
|
|
|
|
selectedDatFile.value = null
|
|
|
|
|
|
waveformParseResult.value = null
|
2026-04-17 08:10:46 +08:00
|
|
|
|
vectorParseResult.value = null
|
2026-04-16 08:15:46 +08:00
|
|
|
|
lastParseErrorMessage.value = ''
|
2026-04-17 08:10:46 +08:00
|
|
|
|
lastVectorParseErrorMessage.value = ''
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-06 16:35:48 +08:00
|
|
|
|
activeChannelIndex.value = 'all'
|
2026-05-07 09:38:06 +08:00
|
|
|
|
resetTrendToolState()
|
2026-04-16 08:15:46 +08:00
|
|
|
|
lastParseErrorMessage.value = ''
|
2026-04-17 08:10:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-04-17 08:10:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
isParsing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:38:06 +08:00
|
|
|
|
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('趋势图图片下载成功')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const downloadTrendData = () => {
|
|
|
|
|
|
if (!hasWaveformData.value) {
|
|
|
|
|
|
ElMessage.warning('暂无可导出的波形数据')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const trendPayload = activeTrendPayload.value
|
2026-05-06 16:35:48 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const header = ['时间', ...trendPayload.series.map(item => item.name)]
|
2026-05-06 16:35:48 +08:00
|
|
|
|
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] ?? ''))
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
return [time, ...trendPayload.series.map(item => item.data[index] ?? '')]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-06 16:35:48 +08:00
|
|
|
|
const csvContent = [isAllChannelsActive.value ? allChannelHeader : header, ...rows]
|
|
|
|
|
|
.map(row => row.join(','))
|
|
|
|
|
|
.join('\n')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
|
|
|
|
|
|
const blobUrl = URL.createObjectURL(blob)
|
|
|
|
|
|
const exportFile = document.createElement('a')
|
2026-05-07 09:38:06 +08:00
|
|
|
|
const fileName = buildTrendExportFileName('csv')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-17 08:10:46 +08:00
|
|
|
|
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.72fr);
|
2026-04-16 08:15:46 +08:00
|
|
|
|
gap: 16px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1280px) {
|
|
|
|
|
|
.waveform-layout {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 20:20:52 +08:00
|
|
|
|
</style>
|