feat(waveform): 添加趋势图动态线宽和平移功能

- 实现趋势图按可见点数动态计算线宽,避免大数据量线条过粗
- 新增平移工具支持图表区域拖拽浏览
- 优化数据缩放事件处理,提升图表交互体验
- 添加线宽分档规则配置,支持不同数据量级的显示优化
- 移除原有的光标测量和峰值定位功能
- 更新图表点击事件为数据缩放事件处理
This commit is contained in:
2026-05-07 11:16:51 +08:00
parent 2babe9d99d
commit 32f324909d
6 changed files with 235 additions and 168 deletions

View File

@@ -85,3 +85,11 @@ PR 应包含:
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
## 趋势图线宽显示规则
涉及 waveform 或其他线形趋势图时,线宽应按当前可见点数动态计算,避免初始化大数据量时线条糊成一片,也避免放大后线条过细。
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
- 主线最大线宽不得超过 `1.6`
- 线宽分档统一为:`>= 200000` 使用 `0.35``100000 - 199999` 使用 `0.45``50000 - 99999` 使用 `0.55``20000 - 49999` 使用 `0.65``10000 - 19999` 使用 `0.75``5000 - 9999` 使用 `0.9``2000 - 4999` 使用 `1``800 - 1999` 使用 `1.2``200 - 799` 使用 `1.4``< 200` 使用 `1.6`

View File

@@ -12,6 +12,10 @@ import * as echarts from 'echarts' // 全引入
// import 'echarts-liquidfill'
// import 'echarts/lib/component/dataZoom'
defineOptions({
name: 'LineChart'
})
const color = [
'var(--el-color-primary)',
'#07CCCA',
@@ -30,15 +34,72 @@ const chartRef = ref<HTMLDivElement>()
const props = defineProps(['options', 'isInterVal', 'pieInterVal', 'group'])
const emit = defineEmits<{
'chart-click': [
'chart-data-zoom': [
value: {
timeLabel: string
value: number
seriesName: string
start: number
end: number
}
]
}>()
let chart: echarts.ECharts | any = null
let isPanPointerDown = false
const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined
const resetChartCursor = () => {
const viewportRoot = getChartViewportRoot()
if (viewportRoot) viewportRoot.style.cursor = ''
isPanPointerDown = false
}
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
const viewportRoot = getChartViewportRoot()
if (!viewportRoot || props.options?.activeTool !== 'pan') {
resetChartCursor()
return
}
// 平移只在图形绘图区内生效,鼠标样式同步限制到同一范围,避免坐标轴和空白区误导操作。
const isInGrid = chart?.containPixel?.({ gridIndex: 0 }, [event.offsetX, event.offsetY])
viewportRoot.style.cursor = isInGrid ? (isPanPointerDown ? 'grabbing' : 'grab') : ''
}
const bindPanCursorEvents = () => {
const zr = chart?.getZr?.()
if (!zr) return
zr.off('mousemove', updatePanCursor)
zr.off('mousedown', handlePanCursorMouseDown)
zr.off('mouseup', handlePanCursorMouseUp)
zr.off('globalout', resetChartCursor)
zr.on('mousemove', updatePanCursor)
zr.on('mousedown', handlePanCursorMouseDown)
zr.on('mouseup', handlePanCursorMouseUp)
zr.on('globalout', resetChartCursor)
}
const unbindPanCursorEvents = () => {
const zr = chart?.getZr?.()
if (!zr) return
zr.off('mousemove', updatePanCursor)
zr.off('mousedown', handlePanCursorMouseDown)
zr.off('mouseup', handlePanCursorMouseUp)
zr.off('globalout', resetChartCursor)
resetChartCursor()
}
function handlePanCursorMouseDown(event: { offsetX: number; offsetY: number }) {
isPanPointerDown = true
updatePanCursor(event)
}
function handlePanCursorMouseUp(event: { offsetX: number; offsetY: number }) {
isPanPointerDown = false
updatePanCursor(event)
}
const resizeHandler = () => {
// 不在视野中的时候不进行resize
if (!chartRef.value) return
@@ -51,6 +112,7 @@ const resizeHandler = () => {
}
const initChart = () => {
if (!props.isInterVal && !props.pieInterVal) {
unbindPanCursorEvents()
chart?.dispose()
}
// chart?.dispose()
@@ -128,6 +190,15 @@ const initChart = () => {
end: 100
}
],
toolbox: {
show: false,
feature: {
dataZoom: {
yAxisIndex: 'none'
}
},
...(props.options?.toolbox || null)
},
color: props.options?.color || color,
series: props.options?.series,
...props.options?.options
@@ -136,16 +207,17 @@ const initChart = () => {
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
chart.off('datazoom')
chart.on('datazoom', (params: any) => {
const zoomPayload = Array.isArray(params.batch) ? params.batch[0] : params
const start = Number(zoomPayload?.start)
const end = Number(zoomPayload?.end)
if (params.componentType !== 'series' || !Number.isFinite(Number(value))) return
if (!Number.isFinite(start) || !Number.isFinite(end)) return
emit('chart-click', {
timeLabel: `${params.name ?? params.dataIndex ?? ''}`,
value: Number(value),
seriesName: `${params.seriesName || ''}`
emit('chart-data-zoom', {
start,
end
})
})
chart.dispatchAction({
@@ -153,6 +225,7 @@ const initChart = () => {
key: 'dataZoomSelect',
dataZoomSelectActive: props.options?.activeTool === 'box-zoom'
})
bindPanCursorEvents()
setTimeout(() => {
chart.resize()
@@ -268,14 +341,14 @@ const handlerXAxis = () => {
let throttle: ReturnType<typeof setTimeout>
// 动态计算table高度
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (!entries.length) return
if (throttle) {
clearTimeout(throttle)
}
throttle = setTimeout(() => {
resizeHandler()
}, 100)
}
})
onMounted(() => {
initChart()
@@ -284,11 +357,12 @@ onMounted(() => {
defineExpose({ initChart })
onBeforeUnmount(() => {
resizeObserver.unobserve(chartRef.value!)
unbindPanCursorEvents()
chart?.dispose()
})
watch(
() => props.options,
(newVal, oldVal) => {
() => {
initChart()
}
)

View File

@@ -10,7 +10,7 @@
<LineChart
:options="group.multiChannelOptions"
:group="group.group"
@chart-click="handleChartClick"
@chart-data-zoom="handleChartDataZoom"
/>
</div>
<template v-else>
@@ -21,14 +21,21 @@
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" @chart-click="handleChartClick" />
<LineChart
:options="item.options"
:group="item.group"
@chart-data-zoom="handleChartDataZoom"
/>
</div>
</div>
</template>
</template>
</div>
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart :options="activeTrendOptions" @chart-click="handleChartClick" />
<LineChart
:options="activeTrendOptions"
@chart-data-zoom="handleChartDataZoom"
/>
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
<div
@@ -38,7 +45,11 @@
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" @chart-click="handleChartClick" />
<LineChart
:options="item.options"
:group="item.group"
@chart-data-zoom="handleChartDataZoom"
/>
</div>
</div>
</div>
@@ -54,7 +65,12 @@
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import type { AllChannelTrendGroup, DisplayMode, SingleChannelTrendOption, TrendChartClickPayload } from './types'
import type {
AllChannelTrendGroup,
DisplayMode,
SingleChannelTrendOption,
TrendChartZoomPayload
} from './types'
defineProps<{
hasWaveformData: boolean
@@ -67,11 +83,11 @@ defineProps<{
}>()
const emit = defineEmits<{
'chart-click': [value: TrendChartClickPayload]
'chart-data-zoom': [value: TrendChartZoomPayload]
}>()
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
emit('chart-data-zoom', value)
}
</script>

View File

@@ -7,11 +7,16 @@
<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-tooltip
v-for="item in group.items"
:key="item.action"
:content="getTrendToolTooltip(item)"
placement="top"
>
<el-button
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
:icon="item.icon"
:disabled="!hasWaveformData"
:disabled="isTrendToolDisabled(item.action)"
circle
@click="handleTrendToolClick(item.action)"
/>
@@ -29,7 +34,7 @@
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
@chart-click="handleChartClick"
@chart-data-zoom="handleChartDataZoom"
/>
<el-dialog
@@ -49,7 +54,7 @@
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
@chart-click="handleChartClick"
@chart-data-zoom="handleChartDataZoom"
/>
</el-dialog>
</section>
@@ -57,14 +62,12 @@
<script setup lang="ts">
import {
Aim,
ArrowDownBold,
ArrowUpBold,
Crop,
Download,
FullScreen,
Picture,
Pointer,
Rank,
RefreshLeft,
ZoomIn,
@@ -77,7 +80,7 @@ import type {
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
TrendChartClickPayload,
TrendChartZoomPayload,
TrendToolAction,
TrendTabValue
} from './types'
@@ -96,12 +99,13 @@ const props = defineProps<{
allChannelTrendGroups: AllChannelTrendGroup[]
lastParseErrorMessage: string
activeTrendToolStates: Partial<Record<TrendToolAction, boolean>>
disabledTrendToolStates?: Partial<Record<TrendToolAction, boolean>>
}>()
const emit = defineEmits<{
'update:activeTrendTab': [value: TrendTabValue]
'trend-tool-action': [value: TrendToolAction]
'chart-click': [value: TrendChartClickPayload]
'chart-data-zoom': [value: TrendChartZoomPayload]
}>()
const fullscreenVisible = ref(false)
@@ -126,13 +130,6 @@ const trendToolGroups: Array<{
{ action: 'reset', label: '恢复', icon: RefreshLeft }
]
},
{
key: 'analysis',
items: [
{ action: 'measure', label: '光标测量', icon: Pointer },
{ action: 'peak', label: '峰值定位', icon: Aim }
]
},
{
key: 'export',
items: [
@@ -153,7 +150,24 @@ const isTrendToolActive = (action: TrendPanelToolAction) => {
return props.activeTrendToolStates[action]
}
const isTrendToolDisabled = (action: TrendPanelToolAction) => {
if (!props.hasWaveformData) return true
if (action === 'fullscreen') return false
return !!props.disabledTrendToolStates?.[action]
}
const getTrendToolTooltip = (item: { action: TrendPanelToolAction; label: string }) => {
if (item.action === 'pan' && isTrendToolDisabled(item.action) && props.hasWaveformData) {
return '请先放大 X 轴或框选局部区域后再平移'
}
return item.label
}
const handleTrendToolClick = (action: TrendPanelToolAction) => {
if (isTrendToolDisabled(action)) return
if (action === 'fullscreen') {
fullscreenVisible.value = true
return
@@ -162,8 +176,8 @@ const handleTrendToolClick = (action: TrendPanelToolAction) => {
emit('trend-tool-action', action)
}
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
emit('chart-data-zoom', value)
}
</script>

View File

@@ -10,8 +10,6 @@ export type TrendToolAction =
| 'box-zoom'
| 'pan'
| 'reset'
| 'measure'
| 'peak'
| 'download-image'
| 'download-data'
@@ -54,8 +52,7 @@ export interface FeatureCardItem {
}>
}
export interface TrendChartClickPayload {
timeLabel: string
value: number
seriesName: string
export interface TrendChartZoomPayload {
start: number
end: number
}

View File

@@ -28,9 +28,10 @@
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
:active-trend-tool-states="activeTrendToolStates"
:disabled-trend-tool-states="disabledTrendToolStates"
@update:active-trend-tab="activeTrendTab = $event"
@trend-tool-action="handleTrendToolAction"
@chart-click="handleTrendChartClick"
@chart-data-zoom="handleTrendChartDataZoom"
/>
<WaveformInfoPanel
@@ -62,7 +63,7 @@ import type {
LabelValueOption,
SingleChannelTrendOption,
SummaryItem,
TrendChartClickPayload,
TrendChartZoomPayload,
TrendToolAction,
TrendTabValue,
ValueMode,
@@ -96,12 +97,6 @@ interface TrendZoomRange {
end: number
}
interface TrendMeasureCursor {
timeLabel: string
value: number
seriesName: string
}
const activeTrendTab = ref<TrendTabValue>('instant')
const activeValueMode = ref<ValueMode>('primary')
const activeDisplayMode = ref<DisplayMode>('single-channel')
@@ -119,9 +114,6 @@ 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: '瞬时波形' },
@@ -353,11 +345,19 @@ const hasWaveformData = computed(() => {
return activeTrendPayload.value.series.length > 0
})
const canPanTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
return hasWaveformData.value && (start > 0 || end < 100)
})
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
pan: activeTrendInteractionMode.value === 'pan',
measure: isMeasureModeActive.value,
peak: isPeakVisible.value
pan: activeTrendInteractionMode.value === 'pan'
}))
const disabledTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
pan: !canPanTrendChart.value
}))
const TREND_AXIS_EXPAND_RATIO = 1.2
@@ -375,6 +375,7 @@ const TREND_GRID_BOTTOM = {
withTimeAxis: '30px',
withoutTimeAxis: '6px'
}
const TREND_LINE_MAX_WIDTH = 1.6
const getAxisPrecision = (step: number) => {
const absStep = Math.abs(step)
@@ -430,13 +431,32 @@ const formatAxisLabel = (value: number, precision: number) => {
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
// 趋势图按当前可见点数调整线宽,避免大数据初始展示时线条过粗。
const resolveTrendVisiblePointCount = (pointCount: number) => {
const { start, end } = trendXZoomRange.value
const visibleRatio = Math.max((end - start) / 100, 0)
return Math.ceil(pointCount * visibleRatio)
}
const resolveTrendLineWidth = (pointCount: number) => {
if (pointCount >= 200000) return 0.35
if (pointCount >= 100000) return 0.45
if (pointCount >= 50000) return 0.55
if (pointCount >= 20000) return 0.65
if (pointCount >= 10000) return 0.75
if (pointCount >= 5000) return 0.9
if (pointCount >= 2000) return 1
if (pointCount >= 800) return 1.2
if (pointCount >= 200) return 1.4
return TREND_LINE_MAX_WIDTH
}
const resetTrendToolState = () => {
trendXZoomRange.value = { start: 0, end: 100 }
trendYZoomScale.value = 1
activeTrendInteractionMode.value = 'none'
isMeasureModeActive.value = false
isPeakVisible.value = false
measureCursors.value = []
}
const zoomTrendXAxis = (ratio: number) => {
@@ -594,7 +614,8 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = T
let axisMax = resolveExpandedAxisBoundary(max, false)
if (shouldUseBalancedAxisBoundary(min, max)) {
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(min), Math.abs(max)))
// 正负幅值接近时,对称边界必须基于已向外扩展后的范围,避免把峰值贴到坐标边界。
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(axisMin), Math.abs(axisMax)))
axisMin = -axisBoundary
axisMax = axisBoundary
}
@@ -643,91 +664,20 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = T
}
}
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)
const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
return seriesList.map(item => {
const visiblePointCount = resolveTrendVisiblePointCount(item.data.length)
return {
name: item.name,
type: 'line',
smooth: true,
symbol: isMeasureModeActive.value || isPeakVisible.value ? 'circle' : 'none',
symbol: 'none',
symbolSize: 3,
data: item.data,
...(markPointData.length
? {
markPoint: {
symbolSize: 12,
itemStyle: {
color: '#f56c6c',
borderColor: '#f56c6c'
lineStyle: {
width: resolveTrendLineWidth(visiblePointCount)
},
label: {
color: '#fff',
fontSize: 10
},
data: markPointData
}
}
: null),
...(measureMarkLine && index === 0 ? { markLine: measureMarkLine } : null)
data: item.data
}
})
}
@@ -853,7 +803,7 @@ const buildTrendChartOptions = (
}
],
color: chartColors,
series: buildTrendSeries(trendPayload, seriesList, yAxisConfig, showTimeAxis)
series: buildTrendSeries(seriesList)
}
}
@@ -875,15 +825,26 @@ const buildSingleChannelTrendOptionsList = (
// 单通道下每张图只保留一个 series需要单独指定对应相色。
return trendPayload.series.map((item, index) => {
const showTimeAxis = resolveShowTimeAxis(index, trendPayload.series.length)
const itemTrendPayload = {
...trendPayload,
min: item.data.length ? Math.min(...item.data) : undefined,
max: item.data.length ? Math.max(...item.data) : undefined,
series: [item]
}
return {
key: `${detailIndex}-${item.name}`,
group: chartGroup,
isLastChart: showTimeAxis,
options: buildTrendChartOptions(trendPayload, [item], [trendColors[index] || defaultPhaseColors[index]], {
options: buildTrendChartOptions(
itemTrendPayload,
itemTrendPayload.series,
[trendColors[index] || defaultPhaseColors[index]],
{
showTimeAxis,
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
})
}
)
}
})
}
@@ -977,18 +938,16 @@ const handleTrendToolAction = async (action: TrendToolAction) => {
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
break
case 'pan':
if (!canPanTrendChart.value) {
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
activeTrendInteractionMode.value = 'none'
break
}
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
break
case 'reset':
resetTrendToolState()
break
case 'measure':
isMeasureModeActive.value = !isMeasureModeActive.value
measureCursors.value = []
break
case 'peak':
isPeakVisible.value = !isPeakVisible.value
break
case 'download-image':
await downloadTrendImage()
break
@@ -1000,16 +959,15 @@ const handleTrendToolAction = async (action: TrendToolAction) => {
}
}
const handleTrendChartClick = (value: TrendChartClickPayload) => {
if (!isMeasureModeActive.value) return
const nextCursor = {
timeLabel: value.timeLabel,
value: value.value,
seriesName: value.seriesName
const handleTrendChartDataZoom = (value: TrendChartZoomPayload) => {
trendXZoomRange.value = {
start: clampPercent(value.start),
end: clampPercent(value.end)
}
measureCursors.value = measureCursors.value.length >= 2 ? [nextCursor] : [...measureCursors.value, nextCursor]
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
activeTrendInteractionMode.value = 'none'
}
}
watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {