feat(waveform): 添加波形图趋势工具和坐标轴规则
- 实现波形图纵坐标显示规则,包括最大最小值显示、刻度均分、对称边界等功能 - 添加多图趋势图对齐规则,确保绘图区左边界一致和标签宽度统一 - 集成图表点击事件发射器,支持时间标签、数值和系列名称的数据传递 - 实现波形图缩放、平移、测量、峰值标记等交互功能 - 添加坐标轴刻度精度控制和可读步长归一化处理 - 实现多图对齐的网格配置和纵坐标标签防重叠机制 - 添加波形图全屏展示和图片导出功能 - 实现趋势图峰值标记点和测量游标功能
This commit is contained in:
20
AGENTS.md
20
AGENTS.md
@@ -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 起点是否仍然对齐。
|
||||
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
|
||||
|
||||
|
||||
|
||||
@@ -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) // 格式化显示为一位小数
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }"
|
||||
<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
|
||||
>
|
||||
<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="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%;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user