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/`。
|
`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 chartRef = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const props = defineProps(['options', 'isInterVal', 'pieInterVal', 'group'])
|
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
|
let chart: echarts.ECharts | any = null
|
||||||
const resizeHandler = () => {
|
const resizeHandler = () => {
|
||||||
// 不在视野中的时候不进行resize
|
// 不在视野中的时候不进行resize
|
||||||
@@ -41,7 +50,6 @@ const resizeHandler = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const initChart = () => {
|
const initChart = () => {
|
||||||
|
|
||||||
if (!props.isInterVal && !props.pieInterVal) {
|
if (!props.isInterVal && !props.pieInterVal) {
|
||||||
chart?.dispose()
|
chart?.dispose()
|
||||||
}
|
}
|
||||||
@@ -127,8 +135,24 @@ const initChart = () => {
|
|||||||
// console.log(options.series,"获取x轴");
|
// console.log(options.series,"获取x轴");
|
||||||
handlerBar(options)
|
handlerBar(options)
|
||||||
|
|
||||||
// 处理柱状图
|
|
||||||
chart.setOption(options, true)
|
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(() => {
|
setTimeout(() => {
|
||||||
chart.resize()
|
chart.resize()
|
||||||
@@ -176,7 +200,7 @@ const handlerYAxis = () => {
|
|||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#000',
|
color: '#000',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
formatter: function (value) {
|
formatter: function (value: number) {
|
||||||
return value.toFixed(0) // 格式化显示为一位小数
|
return value.toFixed(0) // 格式化显示为一位小数
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,17 +68,13 @@
|
|||||||
</el-radio-button>
|
</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-button type="primary" :icon="Download" :disabled="!hasWaveformData" @click="emit('download')">
|
|
||||||
下载数据
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Download, FolderOpened } from '@element-plus/icons-vue'
|
import { FolderOpened } from '@element-plus/icons-vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
|
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
|
||||||
|
|
||||||
@@ -92,7 +88,6 @@ defineProps<{
|
|||||||
activeDisplayMode: DisplayMode
|
activeDisplayMode: DisplayMode
|
||||||
valueModeOptions: LabelValueOption<ValueMode>[]
|
valueModeOptions: LabelValueOption<ValueMode>[]
|
||||||
activeValueMode: ValueMode
|
activeValueMode: ValueMode
|
||||||
hasWaveformData: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -100,7 +95,6 @@ const emit = defineEmits<{
|
|||||||
'update:activeDisplayMode': [value: DisplayMode]
|
'update:activeDisplayMode': [value: DisplayMode]
|
||||||
'update:activeValueMode': [value: ValueMode]
|
'update:activeValueMode': [value: ValueMode]
|
||||||
'waveform-file-change': [event: Event]
|
'waveform-file-change': [event: Event]
|
||||||
download: []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const waveformFileInputRef = ref<HTMLInputElement>()
|
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-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-tab-pane v-for="item in trendTabs" :key="item.value" :label="item.label" :name="item.value" />
|
||||||
</el-tabs>
|
</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>
|
||||||
|
|
||||||
<div class="panel-body">
|
<WaveformTrendChartArea
|
||||||
<div v-if="hasWaveformData && isAllChannelsActive" class="all-channel-list">
|
class="waveform-trend-export-target"
|
||||||
<template v-for="group in allChannelTrendGroups" :key="group.key">
|
:has-waveform-data="hasWaveformData"
|
||||||
<div v-if="activeDisplayMode === 'multi-channel'" class="all-channel-chart">
|
:active-display-mode="activeDisplayMode"
|
||||||
<LineChart :options="group.multiChannelOptions" />
|
:active-trend-options="activeTrendOptions"
|
||||||
</div>
|
:single-channel-trend-options-list="singleChannelTrendOptionsList"
|
||||||
<template v-else>
|
:all-channel-trend-groups="allChannelTrendGroups"
|
||||||
<div
|
:is-all-channels-active="isAllChannelsActive"
|
||||||
v-for="item in group.singleChannelOptionsList"
|
:last-parse-error-message="lastParseErrorMessage"
|
||||||
:key="item.key"
|
@chart-click="handleChartClick"
|
||||||
class="single-channel-card"
|
/>
|
||||||
:class="{ 'single-channel-card--with-axis': item.isLastChart }"
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="fullscreenVisible"
|
||||||
|
class="waveform-fullscreen-dialog"
|
||||||
|
title="波形全屏展示"
|
||||||
|
fullscreen
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<div class="single-channel-chart">
|
<WaveformTrendChartArea
|
||||||
<LineChart :options="item.options" :group="item.group" />
|
class="fullscreen-chart-body"
|
||||||
</div>
|
:has-waveform-data="hasWaveformData"
|
||||||
</div>
|
:active-display-mode="activeDisplayMode"
|
||||||
</template>
|
:active-trend-options="activeTrendOptions"
|
||||||
</template>
|
:single-channel-trend-options-list="singleChannelTrendOptionsList"
|
||||||
</div>
|
:all-channel-trend-groups="allChannelTrendGroups"
|
||||||
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
|
:is-all-channels-active="isAllChannelsActive"
|
||||||
<LineChart :options="activeTrendOptions" />
|
:last-parse-error-message="lastParseErrorMessage"
|
||||||
</div>
|
@chart-click="handleChartClick"
|
||||||
<div v-else-if="hasWaveformData" class="single-channel-list">
|
/>
|
||||||
<div
|
</el-dialog>
|
||||||
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>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 {
|
import type {
|
||||||
AllChannelTrendGroup,
|
AllChannelTrendGroup,
|
||||||
DisplayMode,
|
DisplayMode,
|
||||||
LabelValueOption,
|
LabelValueOption,
|
||||||
SingleChannelTrendOption,
|
SingleChannelTrendOption,
|
||||||
|
TrendChartClickPayload,
|
||||||
|
TrendToolAction,
|
||||||
TrendTabValue
|
TrendTabValue
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
type TrendPanelToolAction = TrendToolAction | 'fullscreen'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
hasWaveformData: boolean
|
hasWaveformData: boolean
|
||||||
isAllChannelsActive: boolean
|
isAllChannelsActive: boolean
|
||||||
activeDisplayMode: DisplayMode
|
activeDisplayMode: DisplayMode
|
||||||
@@ -72,15 +95,76 @@ defineProps<{
|
|||||||
singleChannelTrendOptionsList: SingleChannelTrendOption[]
|
singleChannelTrendOptionsList: SingleChannelTrendOption[]
|
||||||
allChannelTrendGroups: AllChannelTrendGroup[]
|
allChannelTrendGroups: AllChannelTrendGroup[]
|
||||||
lastParseErrorMessage: string
|
lastParseErrorMessage: string
|
||||||
|
activeTrendToolStates: Partial<Record<TrendToolAction, boolean>>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:activeTrendTab': [value: TrendTabValue]
|
'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) => {
|
const handleTrendTabChange = (value: string | number) => {
|
||||||
emit('update:activeTrendTab', value as TrendTabValue)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -97,15 +181,44 @@ const handleTrendTabChange = (value: string | number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
margin-bottom: 12px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-tabs {
|
.trend-tabs {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
flex-shrink: 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) {
|
.trend-tabs :deep(.el-tabs__header) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -118,106 +231,37 @@ const handleTrendTabChange = (value: string | number) => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
:global(.waveform-fullscreen-dialog) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container,
|
:global(.waveform-fullscreen-dialog .el-dialog__body) {
|
||||||
.empty-block {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 8px;
|
padding: 12px;
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-block {
|
.fullscreen-chart-body {
|
||||||
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;
|
|
||||||
min-height: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
.panel-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.trend-tabs {
|
.trend-tabs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-tool-groups {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.trend-tabs :deep(.el-tabs__nav) {
|
.trend-tabs :deep(.el-tabs__nav) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ export type TrendTabValue = 'instant' | 'rms'
|
|||||||
export type ValueMode = 'primary' | 'secondary'
|
export type ValueMode = 'primary' | 'secondary'
|
||||||
export type DisplayMode = 'single-channel' | 'multi-channel'
|
export type DisplayMode = 'single-channel' | 'multi-channel'
|
||||||
export type ChannelSelectValue = number | 'all'
|
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> {
|
export interface LabelValueOption<T extends string | number = string | number> {
|
||||||
label: string
|
label: string
|
||||||
@@ -23,6 +35,8 @@ export interface SingleChannelTrendOption {
|
|||||||
export interface AllChannelTrendGroup {
|
export interface AllChannelTrendGroup {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
|
group: string
|
||||||
|
isLastChart?: boolean
|
||||||
singleChannelOptionsList: SingleChannelTrendOption[]
|
singleChannelOptionsList: SingleChannelTrendOption[]
|
||||||
multiChannelOptions: Record<string, unknown>
|
multiChannelOptions: Record<string, unknown>
|
||||||
}
|
}
|
||||||
@@ -39,3 +53,9 @@ export interface FeatureCardItem {
|
|||||||
value: string
|
value: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrendChartClickPayload {
|
||||||
|
timeLabel: string
|
||||||
|
value: number
|
||||||
|
seriesName: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,10 @@
|
|||||||
:active-display-mode="activeDisplayMode"
|
:active-display-mode="activeDisplayMode"
|
||||||
:value-mode-options="valueModeOptions"
|
:value-mode-options="valueModeOptions"
|
||||||
:active-value-mode="activeValueMode"
|
:active-value-mode="activeValueMode"
|
||||||
:has-waveform-data="hasWaveformData"
|
|
||||||
@update:active-channel-index="activeChannelIndex = $event"
|
@update:active-channel-index="activeChannelIndex = $event"
|
||||||
@update:active-display-mode="activeDisplayMode = $event"
|
@update:active-display-mode="activeDisplayMode = $event"
|
||||||
@update:active-value-mode="activeValueMode = $event"
|
@update:active-value-mode="activeValueMode = $event"
|
||||||
@waveform-file-change="handleWaveformFileChange"
|
@waveform-file-change="handleWaveformFileChange"
|
||||||
@download="downloadTrendData"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="waveform-layout">
|
<div class="waveform-layout">
|
||||||
@@ -29,7 +27,10 @@
|
|||||||
:all-channel-trend-groups="allChannelTrendGroups"
|
:all-channel-trend-groups="allChannelTrendGroups"
|
||||||
:is-all-channels-active="isAllChannelsActive"
|
:is-all-channels-active="isAllChannelsActive"
|
||||||
:last-parse-error-message="lastParseErrorMessage"
|
:last-parse-error-message="lastParseErrorMessage"
|
||||||
|
:active-trend-tool-states="activeTrendToolStates"
|
||||||
@update:active-trend-tab="activeTrendTab = $event"
|
@update:active-trend-tab="activeTrendTab = $event"
|
||||||
|
@trend-tool-action="handleTrendToolAction"
|
||||||
|
@chart-click="handleTrendChartClick"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WaveformInfoPanel
|
<WaveformInfoPanel
|
||||||
@@ -45,8 +46,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
||||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||||
@@ -60,6 +62,8 @@ import type {
|
|||||||
LabelValueOption,
|
LabelValueOption,
|
||||||
SingleChannelTrendOption,
|
SingleChannelTrendOption,
|
||||||
SummaryItem,
|
SummaryItem,
|
||||||
|
TrendChartClickPayload,
|
||||||
|
TrendToolAction,
|
||||||
TrendTabValue,
|
TrendTabValue,
|
||||||
ValueMode,
|
ValueMode,
|
||||||
WaveformDetailOption
|
WaveformDetailOption
|
||||||
@@ -84,7 +88,18 @@ interface WaveformTrendPayload {
|
|||||||
|
|
||||||
interface TrendChartLayoutOptions {
|
interface TrendChartLayoutOptions {
|
||||||
showTimeAxis?: boolean
|
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')
|
const activeTrendTab = ref<TrendTabValue>('instant')
|
||||||
@@ -101,6 +116,12 @@ const vectorParseResult = ref<Waveform.WaveComtradeVectorResultVO | null>(null)
|
|||||||
const lastParseErrorMessage = ref('')
|
const lastParseErrorMessage = ref('')
|
||||||
const lastVectorParseErrorMessage = ref('')
|
const lastVectorParseErrorMessage = ref('')
|
||||||
const waveformFileAccept = '.cfg,.dat'
|
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>[] = [
|
const trendTabs: LabelValueOption<TrendTabValue>[] = [
|
||||||
{ value: 'instant', label: '瞬时波形' },
|
{ value: 'instant', label: '瞬时波形' },
|
||||||
@@ -332,18 +353,56 @@ const hasWaveformData = computed(() => {
|
|||||||
return activeTrendPayload.value.series.length > 0
|
return activeTrendPayload.value.series.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const SYMMETRIC_AXIS_SPLIT_COUNT = 6
|
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
|
||||||
const REGULAR_AXIS_SPLIT_COUNT = 5
|
'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) => {
|
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-')) {
|
if (stepText.includes('e-')) {
|
||||||
return Number(stepText.split('e-')[1] || 0)
|
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) => {
|
const normalizeAxisValue = (value: number, precision: number) => {
|
||||||
@@ -352,24 +411,16 @@ const normalizeAxisValue = (value: number, precision: number) => {
|
|||||||
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
|
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNiceAxisInterval = (value: number) => {
|
const roundAxisValueUp = (value: number) => {
|
||||||
if (!Number.isFinite(value) || value <= 0) return 1
|
const absValue = Math.abs(value)
|
||||||
|
|
||||||
const exponent = Math.floor(Math.log10(value))
|
if (!Number.isFinite(absValue) || absValue === 0) return 0
|
||||||
const magnitude = 10 ** exponent
|
|
||||||
const normalized = value / magnitude
|
|
||||||
const candidates = [1, 2, 2.5, 5, 10]
|
|
||||||
const closestCandidate = candidates.reduce((currentBest, currentValue) => {
|
|
||||||
return Math.abs(currentValue - normalized) < Math.abs(currentBest - normalized) ? currentValue : currentBest
|
|
||||||
}, candidates[0])
|
|
||||||
|
|
||||||
return closestCandidate * magnitude
|
const 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) => {
|
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
|
||||||
const precision = getAxisPrecision(interval)
|
|
||||||
const stepCount = isMin ? Math.floor(value / interval) : Math.ceil(value / interval)
|
|
||||||
return normalizeAxisValue(stepCount * interval, precision)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAxisLabel = (value: number, precision: number) => {
|
const formatAxisLabel = (value: number, precision: number) => {
|
||||||
@@ -377,7 +428,159 @@ const formatAxisLabel = (value: number, precision: number) => {
|
|||||||
return `${normalizeAxisValue(value, precision)}`
|
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 min = Number(trendPayload.min)
|
||||||
const max = Number(trendPayload.max)
|
const max = Number(trendPayload.max)
|
||||||
|
|
||||||
@@ -387,41 +590,148 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const crossesZero = min < 0 && max > 0
|
let axisMin = resolveExpandedAxisBoundary(min, true)
|
||||||
const splitCount = crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : REGULAR_AXIS_SPLIT_COUNT
|
let axisMax = resolveExpandedAxisBoundary(max, false)
|
||||||
const baseValue = crossesZero ? Math.max(Math.abs(min), Math.abs(max)) : max - min
|
|
||||||
const interval = getNiceAxisInterval(baseValue / (crossesZero ? splitCount / 2 : splitCount))
|
|
||||||
const precision = getAxisPrecision(interval)
|
|
||||||
|
|
||||||
let axisMin = roundAxisBoundary(min, interval, true)
|
if (shouldUseBalancedAxisBoundary(min, max)) {
|
||||||
let axisMax = roundAxisBoundary(max, interval, false)
|
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(min), Math.abs(max)))
|
||||||
|
axisMin = -axisBoundary
|
||||||
if (crossesZero) {
|
axisMax = axisBoundary
|
||||||
const axisBound = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
|
||||||
axisMin = normalizeAxisValue(-axisBound, precision)
|
|
||||||
axisMax = normalizeAxisValue(axisBound, precision)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (axisMin === axisMax) {
|
if (axisMin === axisMax) {
|
||||||
axisMin = normalizeAxisValue(axisMin - interval, precision)
|
const fallbackBoundary = Math.max(Math.abs(axisMin), 1) * TREND_AXIS_EXPAND_RATIO
|
||||||
axisMax = normalizeAxisValue(axisMax + interval, precision)
|
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 {
|
return {
|
||||||
name: trendPayload.unit || '',
|
name: trendPayload.unit || '',
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 42,
|
||||||
|
nameTextStyle: {
|
||||||
|
color: axisTextColor,
|
||||||
|
fontSize: 12,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
min: axisMin,
|
min: axisMin,
|
||||||
max: axisMax,
|
max: axisMax,
|
||||||
interval,
|
interval,
|
||||||
splitNumber: crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : Math.max(Math.round((axisMax - axisMin) / interval), 1),
|
splitNumber: axisSplitCount,
|
||||||
minInterval: interval,
|
minInterval: interval,
|
||||||
maxInterval: interval,
|
maxInterval: interval,
|
||||||
// 跨零波形按 0 对称出刻度,避免边界出现 164 / -165 这类不均匀标签。
|
// 纵坐标按数据极值留白后均分,小区间优先使用可读步长,避免标签出现冗长小数。
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
|
showMinLabel: true,
|
||||||
|
showMaxLabel: true,
|
||||||
hideOverlap: true,
|
hideOverlap: true,
|
||||||
formatter: (value: number) => formatAxisLabel(value, precision)
|
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 buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
||||||
const lastIndex = timeLabels.length - 1
|
const lastIndex = timeLabels.length - 1
|
||||||
|
|
||||||
@@ -439,7 +749,7 @@ interface TrendTooltipParam {
|
|||||||
value?: number | string
|
value?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildTrendTooltipFormatter = (unit: string) => {
|
const buildTrendTooltipFormatter = (unit: string, showTime = true) => {
|
||||||
return (params: TrendTooltipParam | TrendTooltipParam[]) => {
|
return (params: TrendTooltipParam | TrendTooltipParam[]) => {
|
||||||
const paramList = Array.isArray(params) ? params : [params]
|
const paramList = Array.isArray(params) ? params : [params]
|
||||||
const firstParam = paramList[0]
|
const firstParam = paramList[0]
|
||||||
@@ -455,6 +765,8 @@ const buildTrendTooltipFormatter = (unit: string) => {
|
|||||||
.join('')
|
.join('')
|
||||||
const timeText = timeValue === undefined ? '--' : `${formatNumber(timeValue, 2)} ms`
|
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>`
|
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,
|
chartColors = currentTrendColors.value,
|
||||||
layoutOptions: TrendChartLayoutOptions = {}
|
layoutOptions: TrendChartLayoutOptions = {}
|
||||||
) => {
|
) => {
|
||||||
const { showTimeAxis = true, showLegend = true } = layoutOptions
|
const { showTimeAxis = true } = layoutOptions
|
||||||
|
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
|
||||||
|
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activeTool: activeTrendInteractionMode.value,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
@@ -478,18 +793,20 @@ const buildTrendChartOptions = (
|
|||||||
width: 1
|
width: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
formatter: buildTrendTooltipFormatter(trendPayload.unit)
|
formatter: buildTrendTooltipFormatter(trendPayload.unit, showTimeAxis)
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
show: showLegend,
|
show: false,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 12
|
right: 12
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
top: showLegend ? '18px' : '6px',
|
top: TREND_GRID_TOP,
|
||||||
left: '24px',
|
// 多图趋势图固定绘图区左边界,避免纵坐标标签宽度不同导致 x=0 起点错位。
|
||||||
right: showTimeAxis ? '32px' : '24px',
|
left: TREND_GRID_LEFT,
|
||||||
bottom: showTimeAxis ? '16px' : '6px'
|
right: TREND_GRID_RIGHT,
|
||||||
|
bottom: showTimeAxis ? TREND_GRID_BOTTOM.withTimeAxis : TREND_GRID_BOTTOM.withoutTimeAxis,
|
||||||
|
containLabel: false
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
data: trendPayload.timeLabels,
|
data: trendPayload.timeLabels,
|
||||||
@@ -497,7 +814,7 @@ const buildTrendChartOptions = (
|
|||||||
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
|
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
|
||||||
name: showTimeAxis ? 'ms' : '',
|
name: showTimeAxis ? 'ms' : '',
|
||||||
nameLocation: 'end',
|
nameLocation: 'end',
|
||||||
nameGap: showTimeAxis ? 10 : 0,
|
nameGap: showTimeAxis ? 6 : 0,
|
||||||
nameTextStyle: {
|
nameTextStyle: {
|
||||||
color: axisTextColor
|
color: axisTextColor
|
||||||
},
|
},
|
||||||
@@ -513,7 +830,7 @@ const buildTrendChartOptions = (
|
|||||||
show: showTimeAxis,
|
show: showTimeAxis,
|
||||||
hideOverlap: false,
|
hideOverlap: false,
|
||||||
interval: 0,
|
interval: 0,
|
||||||
margin: showTimeAxis ? 9 : 0,
|
margin: showTimeAxis ? 10 : 0,
|
||||||
color: axisTextColor,
|
color: axisTextColor,
|
||||||
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
|
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
|
||||||
},
|
},
|
||||||
@@ -524,16 +841,19 @@ const buildTrendChartOptions = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: buildTrendAxisConfig(trendPayload),
|
yAxis: yAxisConfig,
|
||||||
dataZoom: [],
|
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,
|
color: chartColors,
|
||||||
series: seriesList.map(item => ({
|
series: buildTrendSeries(trendPayload, seriesList, yAxisConfig, showTimeAxis)
|
||||||
name: item.name,
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
symbol: 'none',
|
|
||||||
data: item.data
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,9 +881,8 @@ const buildSingleChannelTrendOptionsList = (
|
|||||||
group: chartGroup,
|
group: chartGroup,
|
||||||
isLastChart: showTimeAxis,
|
isLastChart: showTimeAxis,
|
||||||
options: buildTrendChartOptions(trendPayload, [item], [trendColors[index] || defaultPhaseColors[index]], {
|
options: buildTrendChartOptions(trendPayload, [item], [trendColors[index] || defaultPhaseColors[index]], {
|
||||||
// 单通道下每张图只有一条曲线,图例信息与图表标题重复。
|
|
||||||
showTimeAxis,
|
showTimeAxis,
|
||||||
showLegend: false
|
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -592,6 +911,8 @@ const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
|
|||||||
return {
|
return {
|
||||||
key: `${item.index}-${buildChannelLabel(item.detail, item.index)}`,
|
key: `${item.index}-${buildChannelLabel(item.detail, item.index)}`,
|
||||||
title: buildChannelLabel(item.detail, item.index),
|
title: buildChannelLabel(item.detail, item.index),
|
||||||
|
group: allChannelTrendChartGroup,
|
||||||
|
isLastChart: isLastGroup,
|
||||||
singleChannelOptionsList: buildSingleChannelTrendOptionsList(
|
singleChannelOptionsList: buildSingleChannelTrendOptionsList(
|
||||||
item.detail,
|
item.detail,
|
||||||
item.index,
|
item.index,
|
||||||
@@ -601,7 +922,11 @@ const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
|
|||||||
multiChannelOptions: buildTrendChartOptions(
|
multiChannelOptions: buildTrendChartOptions(
|
||||||
item.trendPayload,
|
item.trendPayload,
|
||||||
item.trendPayload.series,
|
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) => {
|
const getFileBaseName = (fileName: string) => {
|
||||||
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
|
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
|
||||||
}
|
}
|
||||||
@@ -685,6 +1069,7 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
|
|||||||
try {
|
try {
|
||||||
isParsing.value = true
|
isParsing.value = true
|
||||||
activeChannelIndex.value = 'all'
|
activeChannelIndex.value = 'all'
|
||||||
|
resetTrendToolState()
|
||||||
lastParseErrorMessage.value = ''
|
lastParseErrorMessage.value = ''
|
||||||
lastVectorParseErrorMessage.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 = () => {
|
const downloadTrendData = () => {
|
||||||
if (!hasWaveformData.value) {
|
if (!hasWaveformData.value) {
|
||||||
ElMessage.warning('暂无可导出的波形数据')
|
ElMessage.warning('暂无可导出的波形数据')
|
||||||
@@ -777,12 +1200,7 @@ const downloadTrendData = () => {
|
|||||||
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
const exportFile = document.createElement('a')
|
const exportFile = document.createElement('a')
|
||||||
const channelLabel = isAllChannelsActive.value
|
const fileName = buildTrendExportFileName('csv')
|
||||||
? '全部'
|
|
||||||
: activeWaveDetail.value
|
|
||||||
? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value as number)
|
|
||||||
: '波形'
|
|
||||||
const fileName = `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.csv`
|
|
||||||
|
|
||||||
exportFile.style.display = 'none'
|
exportFile.style.display = 'none'
|
||||||
exportFile.download = fileName
|
exportFile.download = fileName
|
||||||
|
|||||||
Reference in New Issue
Block a user