diff --git a/AGENTS.md b/AGENTS.md index 6fb5322..a5dac05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 diff --git a/frontend/src/components/echarts/line/index.vue b/frontend/src/components/echarts/line/index.vue index cc110e2..b857c39 100644 --- a/frontend/src/components/echarts/line/index.vue +++ b/frontend/src/components/echarts/line/index.vue @@ -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() 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 // 动态计算table高度 const resizeObserver = new ResizeObserver(entries => { - for (const entry of entries) { - if (throttle) { - clearTimeout(throttle) - } - throttle = setTimeout(() => { - resizeHandler() - }, 100) + 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() } ) diff --git a/frontend/src/views/tools/waveform/components/WaveformTrendChartArea.vue b/frontend/src/views/tools/waveform/components/WaveformTrendChartArea.vue index 14ffbb8..7ea0432 100644 --- a/frontend/src/views/tools/waveform/components/WaveformTrendChartArea.vue +++ b/frontend/src/views/tools/waveform/components/WaveformTrendChartArea.vue @@ -10,7 +10,7 @@
- +
- +
@@ -54,7 +65,12 @@ diff --git a/frontend/src/views/tools/waveform/components/WaveformTrendPanel.vue b/frontend/src/views/tools/waveform/components/WaveformTrendPanel.vue index 6a9a8b0..bb04208 100644 --- a/frontend/src/views/tools/waveform/components/WaveformTrendPanel.vue +++ b/frontend/src/views/tools/waveform/components/WaveformTrendPanel.vue @@ -7,11 +7,16 @@
- + @@ -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" /> @@ -57,14 +62,12 @@ diff --git a/frontend/src/views/tools/waveform/components/types.ts b/frontend/src/views/tools/waveform/components/types.ts index 38f96a4..2dc3b3e 100644 --- a/frontend/src/views/tools/waveform/components/types.ts +++ b/frontend/src/views/tools/waveform/components/types.ts @@ -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 } diff --git a/frontend/src/views/tools/waveform/index.vue b/frontend/src/views/tools/waveform/index.vue index 53a5ce7..ba64cf8 100644 --- a/frontend/src/views/tools/waveform/index.vue +++ b/frontend/src/views/tools/waveform/index.vue @@ -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" /> ('instant') const activeValueMode = ref('primary') const activeDisplayMode = ref('single-channel') @@ -119,9 +114,6 @@ const waveformFileAccept = '.cfg,.dat' const trendXZoomRange = ref({ 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([]) const trendTabs: LabelValueOption[] = [ { 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>>(() => ({ 'box-zoom': activeTrendInteractionMode.value === 'box-zoom', - pan: activeTrendInteractionMode.value === 'pan', - measure: isMeasureModeActive.value, - peak: isPeakVisible.value + pan: activeTrendInteractionMode.value === 'pan' +})) + +const disabledTrendToolStates = computed>>(() => ({ + 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, - 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' - }, - label: { - color: '#fff', - fontSize: 10 - }, - data: markPointData - } - } - : null), - ...(measureMarkLine && index === 0 ? { markLine: measureMarkLine } : null) + lineStyle: { + width: resolveTrendLineWidth(visiblePointCount) + }, + 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]], { - showTimeAxis, - yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT - }) + 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], () => {