feat(waveform): 添加波形图趋势工具和坐标轴规则

- 实现波形图纵坐标显示规则,包括最大最小值显示、刻度均分、对称边界等功能
- 添加多图趋势图对齐规则,确保绘图区左边界一致和标签宽度统一
- 集成图表点击事件发射器,支持时间标签、数值和系列名称的数据传递
- 实现波形图缩放、平移、测量、峰值标记等交互功能
- 添加坐标轴刻度精度控制和可读步长归一化处理
- 实现多图对齐的网格配置和纵坐标标签防重叠机制
- 添加波形图全屏展示和图片导出功能
- 实现趋势图峰值标记点和测量游标功能
This commit is contained in:
2026-05-07 09:38:06 +08:00
parent 407ab0a7f6
commit 2babe9d99d
7 changed files with 903 additions and 209 deletions

View File

@@ -64,4 +64,24 @@ PR 应包含:
## 安全与配置提示
`public/ssl/``build/extraResources/``electron/config/` 包含敏感运行资源。不要硬编码新的密钥或口令;凡是影响打包或启动的本地 `.env`、端口或运行配置调整,都应同步记录到 `doc/`
## 趋势图纵坐标显示规则
涉及 waveform 或其他趋势图纵坐标时,统一遵循以下规则:
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel``showMinLabel`
- 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。
- 纵坐标最大值和最小值基于图形内真实最大值、最小值按 `1.2` 倍扩展;正数下边界使用 `0.8` 倍向下留白,负数上边界使用 `0.8` 倍向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180``-180`
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1``2``2.5``5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
- 纵坐标标签不能重叠。小高度趋势图应减少均分段数,优先保证最大值、最小值和必要中间值可读;高度足够时再增加分段。
## 多图趋势图对齐规则
涉及 waveform 或其他上下堆叠的多张趋势图时,统一遵循以下规则:
- 同一组多图必须保证绘图区左边界一致,纵向观察时各图的 y 轴线、x 轴 `0` 起点和曲线起始位置应上下对齐。
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150``2``-100` 等标签宽度差异导致曲线区域错位。
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom``axisLabel.margin``nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。

View File

@@ -29,6 +29,15 @@ const color = [
const chartRef = ref<HTMLDivElement>()
const props = defineProps(['options', 'isInterVal', 'pieInterVal', 'group'])
const emit = defineEmits<{
'chart-click': [
value: {
timeLabel: string
value: number
seriesName: string
}
]
}>()
let chart: echarts.ECharts | any = null
const resizeHandler = () => {
// 不在视野中的时候不进行resize
@@ -41,7 +50,6 @@ const resizeHandler = () => {
})
}
const initChart = () => {
if (!props.isInterVal && !props.pieInterVal) {
chart?.dispose()
}
@@ -127,8 +135,24 @@ const initChart = () => {
// console.log(options.series,"获取x轴");
handlerBar(options)
// 处理柱状图
chart.setOption(options, true)
chart.off('click')
chart.on('click', (params: any) => {
const value = Array.isArray(params.value) ? params.value[1] : params.value
if (params.componentType !== 'series' || !Number.isFinite(Number(value))) return
emit('chart-click', {
timeLabel: `${params.name ?? params.dataIndex ?? ''}`,
value: Number(value),
seriesName: `${params.seriesName || ''}`
})
})
chart.dispatchAction({
type: 'takeGlobalCursor',
key: 'dataZoomSelect',
dataZoomSelectActive: props.options?.activeTool === 'box-zoom'
})
setTimeout(() => {
chart.resize()
@@ -176,7 +200,7 @@ const handlerYAxis = () => {
axisLabel: {
color: '#000',
fontSize: 14,
formatter: function (value) {
formatter: function (value: number) {
return value.toFixed(0) // 格式化显示为一位小数
}
},

View File

@@ -68,17 +68,13 @@
</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" :icon="Download" :disabled="!hasWaveformData" @click="emit('download')">
下载数据
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Download, FolderOpened } from '@element-plus/icons-vue'
import { FolderOpened } from '@element-plus/icons-vue'
import { ref } from 'vue'
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
@@ -92,7 +88,6 @@ defineProps<{
activeDisplayMode: DisplayMode
valueModeOptions: LabelValueOption<ValueMode>[]
activeValueMode: ValueMode
hasWaveformData: boolean
}>()
const emit = defineEmits<{
@@ -100,7 +95,6 @@ const emit = defineEmits<{
'update:activeDisplayMode': [value: DisplayMode]
'update:activeValueMode': [value: ValueMode]
'waveform-file-change': [event: Event]
download: []
}>()
const waveformFileInputRef = ref<HTMLInputElement>()

View File

@@ -0,0 +1,174 @@
<template>
<div class="panel-body">
<div v-if="hasWaveformData && isAllChannelsActive" class="all-channel-list">
<template v-for="group in allChannelTrendGroups" :key="group.key">
<div
v-if="activeDisplayMode === 'multi-channel'"
class="all-channel-chart"
:class="{ 'trend-chart-block--with-axis': group.isLastChart }"
>
<LineChart
:options="group.multiChannelOptions"
:group="group.group"
@chart-click="handleChartClick"
/>
</div>
<template v-else>
<div
v-for="item in group.singleChannelOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" @chart-click="handleChartClick" />
</div>
</div>
</template>
</template>
</div>
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart :options="activeTrendOptions" @chart-click="handleChartClick" />
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
<div
v-for="item in singleChannelTrendOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" @chart-click="handleChartClick" />
</div>
</div>
</div>
<div v-else class="empty-block">
<div class="empty-title">暂无波形数据</div>
<div class="empty-text">请选择同一组 `.cfg` `.dat` 文件后自动解析并展示</div>
<div v-if="lastParseErrorMessage" class="empty-text error-text">
最近一次解析失败{{ lastParseErrorMessage }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import type { AllChannelTrendGroup, DisplayMode, SingleChannelTrendOption, TrendChartClickPayload } from './types'
defineProps<{
hasWaveformData: boolean
isAllChannelsActive: boolean
activeDisplayMode: DisplayMode
activeTrendOptions: Record<string, unknown>
singleChannelTrendOptionsList: SingleChannelTrendOption[]
allChannelTrendGroups: AllChannelTrendGroup[]
lastParseErrorMessage: string
}>()
const emit = defineEmits<{
'chart-click': [value: TrendChartClickPayload]
}>()
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
}
</script>
<style scoped lang="scss">
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chart-container,
.empty-block {
display: flex;
flex: 1;
min-height: 0;
padding: 6px 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
}
.empty-block {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: hidden;
}
.all-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 6px 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.all-channel-chart {
display: flex;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 6px 8px;
overflow: hidden;
background: var(--cn-color-canvas-bg);
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
}
.trend-chart-block--with-axis {
flex-basis: 17px;
}
.single-channel-chart {
flex: 1;
min-height: 0;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
</style>

View File

@@ -4,65 +4,88 @@
<el-tabs :model-value="activeTrendTab" class="trend-tabs" @update:model-value="handleTrendTabChange">
<el-tab-pane v-for="item in trendTabs" :key="item.value" :label="item.label" :name="item.value" />
</el-tabs>
<div class="trend-tool-groups">
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
<el-tooltip v-for="item in group.items" :key="item.action" :content="item.label" placement="top">
<el-button
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
:icon="item.icon"
:disabled="!hasWaveformData"
circle
@click="handleTrendToolClick(item.action)"
/>
</el-tooltip>
</div>
</div>
</div>
<div class="panel-body">
<div v-if="hasWaveformData && isAllChannelsActive" class="all-channel-list">
<template v-for="group in allChannelTrendGroups" :key="group.key">
<div v-if="activeDisplayMode === 'multi-channel'" class="all-channel-chart">
<LineChart :options="group.multiChannelOptions" />
</div>
<template v-else>
<div
v-for="item in group.singleChannelOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'single-channel-card--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" />
</div>
</div>
</template>
</template>
</div>
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart :options="activeTrendOptions" />
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
<div
v-for="item in singleChannelTrendOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'single-channel-card--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" />
</div>
</div>
</div>
<div v-else class="empty-block">
<div class="empty-title">暂无波形数据</div>
<div class="empty-text">请选择同一组 `.cfg` `.dat` 文件后自动解析并展示</div>
<div v-if="lastParseErrorMessage" class="empty-text error-text">
最近一次解析失败{{ lastParseErrorMessage }}
</div>
</div>
</div>
<WaveformTrendChartArea
class="waveform-trend-export-target"
:has-waveform-data="hasWaveformData"
:active-display-mode="activeDisplayMode"
: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"
@chart-click="handleChartClick"
/>
<el-dialog
v-model="fullscreenVisible"
class="waveform-fullscreen-dialog"
title="波形全屏展示"
fullscreen
append-to-body
destroy-on-close
>
<WaveformTrendChartArea
class="fullscreen-chart-body"
:has-waveform-data="hasWaveformData"
:active-display-mode="activeDisplayMode"
: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"
@chart-click="handleChartClick"
/>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import {
Aim,
ArrowDownBold,
ArrowUpBold,
Crop,
Download,
FullScreen,
Picture,
Pointer,
Rank,
RefreshLeft,
ZoomIn,
ZoomOut
} from '@element-plus/icons-vue'
import { ref } from 'vue'
import WaveformTrendChartArea from './WaveformTrendChartArea.vue'
import type {
AllChannelTrendGroup,
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
TrendChartClickPayload,
TrendToolAction,
TrendTabValue
} from './types'
import type { Component } from 'vue'
defineProps<{
type TrendPanelToolAction = TrendToolAction | 'fullscreen'
const props = defineProps<{
hasWaveformData: boolean
isAllChannelsActive: boolean
activeDisplayMode: DisplayMode
@@ -72,15 +95,76 @@ defineProps<{
singleChannelTrendOptionsList: SingleChannelTrendOption[]
allChannelTrendGroups: AllChannelTrendGroup[]
lastParseErrorMessage: string
activeTrendToolStates: Partial<Record<TrendToolAction, boolean>>
}>()
const emit = defineEmits<{
'update:activeTrendTab': [value: TrendTabValue]
'trend-tool-action': [value: TrendToolAction]
'chart-click': [value: TrendChartClickPayload]
}>()
const fullscreenVisible = ref(false)
const trendToolGroups: Array<{
key: string
items: Array<{
action: TrendPanelToolAction
label: string
icon: Component
}>
}> = [
{
key: 'scale',
items: [
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ZoomIn },
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ZoomOut },
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
{ action: 'box-zoom', label: '框选放大', icon: Crop },
{ action: 'pan', label: '平移', icon: Rank },
{ action: 'reset', label: '恢复', icon: RefreshLeft }
]
},
{
key: 'analysis',
items: [
{ action: 'measure', label: '光标测量', icon: Pointer },
{ action: 'peak', label: '峰值定位', icon: Aim }
]
},
{
key: 'export',
items: [
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
{ action: 'download-image', label: '下载图片', icon: Picture },
{ action: 'download-data', label: '下载数据', icon: Download }
]
}
]
const handleTrendTabChange = (value: string | number) => {
emit('update:activeTrendTab', value as TrendTabValue)
}
const isTrendToolActive = (action: TrendPanelToolAction) => {
if (action === 'fullscreen') return fullscreenVisible.value
return props.activeTrendToolStates[action]
}
const handleTrendToolClick = (action: TrendPanelToolAction) => {
if (action === 'fullscreen') {
fullscreenVisible.value = true
return
}
emit('trend-tool-action', action)
}
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
}
</script>
<style scoped lang="scss">
@@ -97,15 +181,44 @@ const handleTrendTabChange = (value: string | number) => {
}
.panel-header {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-bottom: 12px;
}
.trend-tabs {
width: 100%;
min-width: 0;
flex-shrink: 0;
}
.trend-tool-groups {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.trend-tool-group {
display: flex;
align-items: center;
gap: 4px;
}
.trend-tool-group + .trend-tool-group {
padding-left: 8px;
border-left: 1px solid var(--el-border-color-lighter);
}
.trend-tool-group :deep(.el-button.is-circle) {
width: 28px;
height: 28px;
padding: 6px;
}
.trend-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
@@ -118,106 +231,37 @@ const handleTrendTabChange = (value: string | number) => {
font-size: 13px;
}
.panel-body {
:global(.waveform-fullscreen-dialog) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chart-container,
.empty-block {
:global(.waveform-fullscreen-dialog .el-dialog__body) {
display: flex;
flex: 1;
min-height: 0;
padding: 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
padding: 12px;
}
.empty-block {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
.fullscreen-chart-body {
min-height: 0;
overflow: hidden;
}
.all-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.all-channel-chart {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
overflow: hidden;
}
.single-channel-card--with-axis {
flex: 1.1;
}
.single-channel-chart {
flex: 1;
min-height: 0;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
@media (max-width: 768px) {
.panel-header {
flex-direction: column;
align-items: stretch;
}
.trend-tabs {
width: 100%;
}
.trend-tool-groups {
flex-wrap: wrap;
justify-content: flex-start;
}
.trend-tabs :deep(.el-tabs__nav) {
width: 100%;
}

View File

@@ -2,6 +2,18 @@ export type TrendTabValue = 'instant' | 'rms'
export type ValueMode = 'primary' | 'secondary'
export type DisplayMode = 'single-channel' | 'multi-channel'
export type ChannelSelectValue = number | 'all'
export type TrendToolAction =
| 'x-zoom-in'
| 'x-zoom-out'
| 'y-zoom-in'
| 'y-zoom-out'
| 'box-zoom'
| 'pan'
| 'reset'
| 'measure'
| 'peak'
| 'download-image'
| 'download-data'
export interface LabelValueOption<T extends string | number = string | number> {
label: string
@@ -23,6 +35,8 @@ export interface SingleChannelTrendOption {
export interface AllChannelTrendGroup {
key: string
title: string
group: string
isLastChart?: boolean
singleChannelOptionsList: SingleChannelTrendOption[]
multiChannelOptions: Record<string, unknown>
}
@@ -39,3 +53,9 @@ export interface FeatureCardItem {
value: string
}>
}
export interface TrendChartClickPayload {
timeLabel: string
value: number
seriesName: string
}

View File

@@ -10,12 +10,10 @@
:active-display-mode="activeDisplayMode"
:value-mode-options="valueModeOptions"
:active-value-mode="activeValueMode"
:has-waveform-data="hasWaveformData"
@update:active-channel-index="activeChannelIndex = $event"
@update:active-display-mode="activeDisplayMode = $event"
@update:active-value-mode="activeValueMode = $event"
@waveform-file-change="handleWaveformFileChange"
@download="downloadTrendData"
/>
<div class="waveform-layout">
@@ -29,7 +27,10 @@
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
:active-trend-tool-states="activeTrendToolStates"
@update:active-trend-tab="activeTrendTab = $event"
@trend-tool-action="handleTrendToolAction"
@chart-click="handleTrendChartClick"
/>
<WaveformInfoPanel
@@ -45,8 +46,9 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
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'
@@ -60,6 +62,8 @@ import type {
LabelValueOption,
SingleChannelTrendOption,
SummaryItem,
TrendChartClickPayload,
TrendToolAction,
TrendTabValue,
ValueMode,
WaveformDetailOption
@@ -84,7 +88,18 @@ interface WaveformTrendPayload {
interface TrendChartLayoutOptions {
showTimeAxis?: boolean
showLegend?: boolean
yAxisSplitCount?: number
}
interface TrendZoomRange {
start: number
end: number
}
interface TrendMeasureCursor {
timeLabel: string
value: number
seriesName: string
}
const activeTrendTab = ref<TrendTabValue>('instant')
@@ -101,6 +116,12 @@ 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 isMeasureModeActive = ref(false)
const isPeakVisible = ref(false)
const measureCursors = ref<TrendMeasureCursor[]>([])
const trendTabs: LabelValueOption<TrendTabValue>[] = [
{ value: 'instant', label: '瞬时波形' },
@@ -332,18 +353,56 @@ const hasWaveformData = computed(() => {
return activeTrendPayload.value.series.length > 0
})
const SYMMETRIC_AXIS_SPLIT_COUNT = 6
const REGULAR_AXIS_SPLIT_COUNT = 5
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
pan: activeTrendInteractionMode.value === 'pan',
measure: isMeasureModeActive.value,
peak: isPeakVisible.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 = '18px'
const TREND_GRID_BOTTOM = {
withTimeAxis: '30px',
withoutTimeAxis: '6px'
}
const getAxisPrecision = (step: number) => {
if (!Number.isFinite(step) || step >= 1) return 0
const absStep = Math.abs(step)
const stepText = `${step}`
if (!Number.isFinite(absStep) || absStep >= 1) return 0
const stepText = `${absStep}`
if (stepText.includes('e-')) {
return Number(stepText.split('e-')[1] || 0)
}
return stepText.split('.')[1]?.length || 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) => {
@@ -352,24 +411,16 @@ const normalizeAxisValue = (value: number, precision: number) => {
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
}
const getNiceAxisInterval = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return 1
const roundAxisValueUp = (value: number) => {
const absValue = Math.abs(value)
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])
if (!Number.isFinite(absValue) || absValue === 0) return 0
return closestCandidate * magnitude
}
const magnitude = 10 ** Math.floor(Math.log10(absValue))
const step = magnitude >= 10 ? magnitude / 10 : magnitude
const precision = getAxisPrecision(step)
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)
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
}
const formatAxisLabel = (value: number, precision: number) => {
@@ -377,7 +428,159 @@ const formatAxisLabel = (value: number, precision: number) => {
return `${normalizeAxisValue(value, precision)}`
}
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
const resetTrendToolState = () => {
trendXZoomRange.value = { start: 0, end: 100 }
trendYZoomScale.value = 1
activeTrendInteractionMode.value = 'none'
isMeasureModeActive.value = false
isPeakVisible.value = false
measureCursors.value = []
}
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)
@@ -387,41 +590,148 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
}
}
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 = resolveExpandedAxisBoundary(min, true)
let axisMax = resolveExpandedAxisBoundary(max, false)
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 (shouldUseBalancedAxisBoundary(min, max)) {
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(min), Math.abs(max)))
axisMin = -axisBoundary
axisMax = axisBoundary
}
if (axisMin === axisMax) {
axisMin = normalizeAxisValue(axisMin - interval, precision)
axisMax = normalizeAxisValue(axisMax + interval, precision)
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: crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : Math.max(Math.round((axisMax - axisMin) / interval), 1),
splitNumber: axisSplitCount,
minInterval: interval,
maxInterval: interval,
// 跨零波形按 0 对称出刻度,避免边界出现 164 / -165 这类不均匀标签
// 纵坐标按数据极值留白后均分,小区间优先使用可读步长,避免标签出现冗长小数
axisLabel: {
showMinLabel: true,
showMaxLabel: true,
hideOverlap: true,
formatter: (value: number) => formatAxisLabel(value, precision)
}
}
}
const buildPeakMarkPointData = (trendPayload: WaveformTrendPayload, item: WaveformSeriesItem) => {
if (!isPeakVisible.value || !item.data.length) return []
const maxValue = Math.max(...item.data)
const minValue = Math.min(...item.data)
const maxIndex = item.data.findIndex(value => value === maxValue)
const minIndex = item.data.findIndex(value => value === minValue)
const buildPeakItem = (name: string, index: number, value: number) => ({
name,
coord: [trendPayload.timeLabels[index], value],
value: formatNumber(value),
symbol: 'pin',
symbolSize: 36,
label: {
formatter: name
}
})
return [
...(maxIndex >= 0 ? [buildPeakItem('最大值', maxIndex, maxValue)] : []),
...(minIndex >= 0 && minIndex !== maxIndex ? [buildPeakItem('最小值', minIndex, minValue)] : [])
]
}
const buildMeasureMarkLine = (unit: string) => {
if (measureCursors.value.length < 2) return null
const [firstCursor, secondCursor] = measureCursors.value
const deltaTime = Number(secondCursor.timeLabel) - Number(firstCursor.timeLabel)
const deltaValue = secondCursor.value - firstCursor.value
const valueText = unit ? `${formatNumber(deltaValue)} ${unit}` : formatNumber(deltaValue)
return {
silent: true,
symbol: ['none', 'none'],
lineStyle: {
color: '#e6a23c',
type: 'dashed',
width: 1
},
label: {
show: true,
formatter: `Δt ${formatNumber(deltaTime, 2)} ms / ΔY ${valueText}`,
color: '#e6a23c',
position: 'insideEndTop'
},
data: [{ xAxis: firstCursor.timeLabel }, { xAxis: secondCursor.timeLabel }]
}
}
const buildTrendSeries = (
trendPayload: WaveformTrendPayload,
seriesList: WaveformSeriesItem[],
yAxisConfig: Record<string, unknown>,
showTimeAxis: boolean
) => {
const measureMarkLine = showTimeAxis ? buildMeasureMarkLine(trendPayload.unit) : null
return seriesList.map((item, index) => {
const markPointData = buildPeakMarkPointData(trendPayload, item)
return {
name: item.name,
type: 'line',
smooth: true,
symbol: isMeasureModeActive.value || isPeakVisible.value ? 'circle' : 'none',
symbolSize: 3,
data: item.data,
...(markPointData.length
? {
markPoint: {
symbolSize: 12,
itemStyle: {
color: '#f56c6c',
borderColor: '#f56c6c'
},
label: {
color: '#fff',
fontSize: 10
},
data: markPointData
}
}
: null),
...(measureMarkLine && index === 0 ? { markLine: measureMarkLine } : null)
}
})
}
const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1
@@ -439,7 +749,7 @@ interface TrendTooltipParam {
value?: number | string
}
const buildTrendTooltipFormatter = (unit: string) => {
const buildTrendTooltipFormatter = (unit: string, showTime = true) => {
return (params: TrendTooltipParam | TrendTooltipParam[]) => {
const paramList = Array.isArray(params) ? params : [params]
const firstParam = paramList[0]
@@ -455,6 +765,8 @@ const buildTrendTooltipFormatter = (unit: string) => {
.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>`
}
}
@@ -465,9 +777,12 @@ const buildTrendChartOptions = (
chartColors = currentTrendColors.value,
layoutOptions: TrendChartLayoutOptions = {}
) => {
const { showTimeAxis = true, showLegend = true } = layoutOptions
const { showTimeAxis = true } = layoutOptions
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
return {
activeTool: activeTrendInteractionMode.value,
tooltip: {
trigger: 'axis',
axisPointer: {
@@ -478,18 +793,20 @@ const buildTrendChartOptions = (
width: 1
}
},
formatter: buildTrendTooltipFormatter(trendPayload.unit)
formatter: buildTrendTooltipFormatter(trendPayload.unit, showTimeAxis)
},
legend: {
show: showLegend,
show: false,
top: 0,
right: 12
},
grid: {
top: showLegend ? '18px' : '6px',
left: '24px',
right: showTimeAxis ? '32px' : '24px',
bottom: showTimeAxis ? '16px' : '6px'
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,
@@ -497,7 +814,7 @@ const buildTrendChartOptions = (
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
name: showTimeAxis ? 'ms' : '',
nameLocation: 'end',
nameGap: showTimeAxis ? 10 : 0,
nameGap: showTimeAxis ? 6 : 0,
nameTextStyle: {
color: axisTextColor
},
@@ -513,7 +830,7 @@ const buildTrendChartOptions = (
show: showTimeAxis,
hideOverlap: false,
interval: 0,
margin: showTimeAxis ? 9 : 0,
margin: showTimeAxis ? 10 : 0,
color: axisTextColor,
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
},
@@ -524,16 +841,19 @@ const buildTrendChartOptions = (
}
}
},
yAxis: buildTrendAxisConfig(trendPayload),
dataZoom: [],
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'
}
],
color: chartColors,
series: seriesList.map(item => ({
name: item.name,
type: 'line',
smooth: true,
symbol: 'none',
data: item.data
}))
series: buildTrendSeries(trendPayload, seriesList, yAxisConfig, showTimeAxis)
}
}
@@ -561,9 +881,8 @@ const buildSingleChannelTrendOptionsList = (
group: chartGroup,
isLastChart: showTimeAxis,
options: buildTrendChartOptions(trendPayload, [item], [trendColors[index] || defaultPhaseColors[index]], {
// 单通道下每张图只有一条曲线,图例信息与图表标题重复。
showTimeAxis,
showLegend: false
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
})
}
})
@@ -592,6 +911,8 @@ const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
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,
@@ -601,7 +922,11 @@ const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
multiChannelOptions: buildTrendChartOptions(
item.trendPayload,
item.trendPayload.series,
getTrendColors(item.detail)
getTrendColors(item.detail),
{
showTimeAxis: isLastGroup,
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
}
)
}
})
@@ -632,6 +957,65 @@ const summaryItems = computed<SummaryItem[]>(() => {
]
})
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':
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
break
case 'reset':
resetTrendToolState()
break
case 'measure':
isMeasureModeActive.value = !isMeasureModeActive.value
measureCursors.value = []
break
case 'peak':
isPeakVisible.value = !isPeakVisible.value
break
case 'download-image':
await downloadTrendImage()
break
case 'download-data':
downloadTrendData()
break
default:
break
}
}
const handleTrendChartClick = (value: TrendChartClickPayload) => {
if (!isMeasureModeActive.value) return
const nextCursor = {
timeLabel: value.timeLabel,
value: value.value,
seriesName: value.seriesName
}
measureCursors.value = measureCursors.value.length >= 2 ? [nextCursor] : [...measureCursors.value, nextCursor]
}
watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {
resetTrendToolState()
})
const getFileBaseName = (fileName: string) => {
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
}
@@ -685,6 +1069,7 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
try {
isParsing.value = true
activeChannelIndex.value = 'all'
resetTrendToolState()
lastParseErrorMessage.value = ''
lastVectorParseErrorMessage.value = ''
@@ -735,6 +1120,44 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
}
}
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('暂无可导出的波形数据')
@@ -777,12 +1200,7 @@ const downloadTrendData = () => {
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
const blobUrl = URL.createObjectURL(blob)
const exportFile = document.createElement('a')
const channelLabel = isAllChannelsActive.value
? '全部'
: activeWaveDetail.value
? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value as number)
: '波形'
const fileName = `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.csv`
const fileName = buildTrendExportFileName('csv')
exportFile.style.display = 'none'
exportFile.download = fileName