- 更新纵坐标刻度算法,优化小数趋势图范围显示 - 添加稳态趋势图全屏模式和共享工具组件 - 实现多图联动的鼠标悬停竖线同步功能 - 调整主线线宽分档策略,降低最大线宽限制 - 重构稳态趋势工具栏,优化谐波次数选择逻辑 - 添加周时间周期搜索支持和自定义时间范围选择 - 完善稳态数据表格和指示器浮动面板功能 - 优化稳态趋势图性能,添加LTB采样和动画控制 - 修复数据表格打开前的趋势数据验证问题 - 统一时间轴标签格式化和网格对齐处理
445 lines
15 KiB
Vue
445 lines
15 KiB
Vue
<template>
|
||
<section class="card trend-chart-panel">
|
||
<div class="panel-header">
|
||
<SteadyTrendChartTools
|
||
:tool-groups="trendToolGroups"
|
||
:is-tool-active="isTrendToolActive"
|
||
:is-tool-disabled="isTrendToolDisabled"
|
||
:get-tool-tooltip="getTrendToolTooltip"
|
||
@tool-action="handleTrendToolAction"
|
||
/>
|
||
<span v-if="trendResult" class="panel-meta">总点数:{{ trendResult.displayPointCount || 0 }}</span>
|
||
</div>
|
||
|
||
<div class="chart-panel-body" v-loading="loading">
|
||
<div
|
||
v-if="hasSeries"
|
||
ref="chartExportTargetRef"
|
||
class="chart-list steady-trend-export-target"
|
||
:style="{ '--steady-trend-visible-chart-count': normalVisibleChartCount }"
|
||
>
|
||
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
||
<div class="chart-body">
|
||
<SteadyTrendChartRenderer
|
||
:group="group"
|
||
:zoom-range="trendXZoomRange"
|
||
:active-tool="activeTrendInteractionMode"
|
||
:wheel-zoom-enabled="wheelZoomEnabled"
|
||
:y-zoom-scale="trendYZoomScale"
|
||
:show-time-axis="chartGroups.indexOf(group) === chartGroups.length - 1"
|
||
@chart-data-zoom="handleChartDataZoom"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||
</div>
|
||
|
||
<SteadyTrendFullscreen
|
||
v-model="fullscreenVisible"
|
||
:chart-groups="chartGroups"
|
||
:visible-chart-count="fullscreenVisibleChartCount"
|
||
:tool-groups="fullscreenToolGroups"
|
||
:zoom-range="trendXZoomRange"
|
||
:active-tool="activeTrendInteractionMode"
|
||
:wheel-zoom-enabled="wheelZoomEnabled"
|
||
:y-zoom-scale="trendYZoomScale"
|
||
:is-tool-active="isTrendToolActive"
|
||
:is-tool-disabled="isTrendToolDisabled"
|
||
:get-tool-tooltip="getTrendToolTooltip"
|
||
@chart-data-zoom="handleChartDataZoom"
|
||
@tool-action="handleTrendToolAction"
|
||
/>
|
||
|
||
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
ArrowDownBold,
|
||
ArrowLeftBold,
|
||
ArrowRightBold,
|
||
ArrowUpBold,
|
||
Crop,
|
||
DataAnalysis,
|
||
DataLine,
|
||
FullScreen,
|
||
Mouse,
|
||
Picture,
|
||
Pointer,
|
||
RefreshLeft
|
||
} from '@element-plus/icons-vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import html2canvas from 'html2canvas'
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||
import { isSteadyTrendLargeDataset } from '../utils/chartRenderer'
|
||
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
||
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||
import SteadyTrendChartRenderer from './SteadyTrendChartRenderer.vue'
|
||
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
||
import SteadyTrendFullscreen from './SteadyTrendFullscreen.vue'
|
||
|
||
defineOptions({
|
||
name: 'SteadyTrendChartPanel'
|
||
})
|
||
|
||
const props = defineProps<{
|
||
trendResult: SteadyTrend.SteadyTrendQueryResult | null
|
||
loading: boolean
|
||
}>()
|
||
|
||
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
||
const trendToolGroups: SteadyTrendToolGroup[] = [
|
||
{
|
||
key: 'viewport',
|
||
items: [
|
||
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||
{ action: 'pan', label: '平移', icon: Pointer },
|
||
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse }
|
||
]
|
||
},
|
||
{
|
||
key: 'view',
|
||
items: [
|
||
{ action: 'missing-data', label: '缺失数据', icon: DataLine },
|
||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
||
]
|
||
}
|
||
]
|
||
|
||
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
||
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 100 }
|
||
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
|
||
const STEADY_TREND_HALF_RANGE_DAYS = 20
|
||
const STEADY_TREND_QUARTER_RANGE_DAYS = 30
|
||
const STEADY_TREND_TENTH_RANGE_DAYS = 60
|
||
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||
const defaultTrendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||
const trendYZoomScale = ref(1)
|
||
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
||
const wheelZoomEnabled = ref(false)
|
||
const missingDataEnabled = ref(false)
|
||
const fullscreenVisible = ref(false)
|
||
const dataTableVisible = ref(false)
|
||
const chartExportTargetRef = ref<HTMLElement>()
|
||
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
||
const isLargeTrendDataset = computed(() => isSteadyTrendLargeDataset(props.trendResult?.series || []))
|
||
const chartGroups = computed(() =>
|
||
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
||
activeTool: activeTrendInteractionMode.value,
|
||
wheelZoomEnabled: wheelZoomEnabled.value,
|
||
showMissingData: missingDataEnabled.value,
|
||
yZoomScale: trendYZoomScale.value
|
||
})
|
||
)
|
||
const normalVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 3), 1))
|
||
const fullscreenVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 6), 1))
|
||
const fullscreenToolGroups = computed(() =>
|
||
trendToolGroups
|
||
.map(group => ({
|
||
...group,
|
||
items: group.items.filter(item => item.action !== 'fullscreen')
|
||
}))
|
||
.filter(group => group.items.length)
|
||
)
|
||
const canPanTrendChart = computed(() => {
|
||
const { start, end } = trendXZoomRange.value
|
||
|
||
return hasSeries.value && (start > 0 || end < 100)
|
||
})
|
||
const isDefaultTrendXZoomRange = computed(() => {
|
||
const { start, end } = trendXZoomRange.value
|
||
const defaultRange = defaultTrendXZoomRange.value
|
||
|
||
return start === defaultRange.start && end === defaultRange.end
|
||
})
|
||
const canResetTrendChart = computed(() => {
|
||
const changedYZoom = trendYZoomScale.value !== 1
|
||
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
||
const changedWheelZoom = wheelZoomEnabled.value
|
||
|
||
return hasSeries.value && (!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
||
})
|
||
|
||
const parseSteadyTrendTime = (value?: string) => {
|
||
if (!value) return null
|
||
|
||
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||
|
||
return Number.isFinite(timestamp) ? timestamp : null
|
||
}
|
||
|
||
const resolveSteadyTrendTimeRangeMs = (trendResult: SteadyTrend.SteadyTrendQueryResult | null) => {
|
||
let minTime = Number.POSITIVE_INFINITY
|
||
let maxTime = Number.NEGATIVE_INFINITY
|
||
|
||
trendResult?.series?.forEach(series => {
|
||
series.points?.forEach(point => {
|
||
const timestamp = parseSteadyTrendTime(point.time)
|
||
|
||
if (timestamp === null) return
|
||
|
||
minTime = Math.min(minTime, timestamp)
|
||
maxTime = Math.max(maxTime, timestamp)
|
||
})
|
||
})
|
||
|
||
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime ? maxTime - minTime : 0
|
||
}
|
||
|
||
const resolveSteadyTrendDefaultZoomRange = (trendResult: SteadyTrend.SteadyTrendQueryResult | null) => {
|
||
const timeRangeMs = resolveSteadyTrendTimeRangeMs(trendResult)
|
||
const timeRangeDays = timeRangeMs / STEADY_TREND_DAY_MS
|
||
|
||
if (timeRangeDays > STEADY_TREND_TENTH_RANGE_DAYS) return { start: 0, end: 10 }
|
||
if (timeRangeDays > STEADY_TREND_QUARTER_RANGE_DAYS) return { start: 0, end: 25 }
|
||
if (timeRangeDays > STEADY_TREND_HALF_RANGE_DAYS) return { start: 0, end: 50 }
|
||
|
||
return { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
||
}
|
||
|
||
const resetTrendToolState = () => {
|
||
trendXZoomRange.value = { ...defaultTrendXZoomRange.value }
|
||
trendYZoomScale.value = 1
|
||
activeTrendInteractionMode.value = 'none'
|
||
wheelZoomEnabled.value = false
|
||
}
|
||
|
||
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 isTrendToolActive = (action: SteadyTrendToolAction) => {
|
||
if (action === 'fullscreen') return fullscreenVisible.value
|
||
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
||
if (action === 'missing-data') return missingDataEnabled.value
|
||
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
||
|
||
return false
|
||
}
|
||
|
||
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
||
if (action === 'query-data') return false
|
||
if (!hasSeries.value) return true
|
||
if (action === 'pan') return !canPanTrendChart.value
|
||
if (action === 'reset') return !canResetTrendChart.value
|
||
|
||
return false
|
||
}
|
||
|
||
const getTrendToolTooltip = (item: SteadyTrendToolItem) => {
|
||
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
||
return '请先放大 X 轴或框选局部区域后再平移'
|
||
}
|
||
|
||
return item.label
|
||
}
|
||
|
||
const downloadSteadyTrendImage = async () => {
|
||
await nextTick()
|
||
|
||
const targetElement = fullscreenVisible.value
|
||
? (document.querySelector('.steady-trend-fullscreen__chart-list') as HTMLElement | null)
|
||
: chartExportTargetRef.value
|
||
|
||
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 = `steady-trend-${Date.now()}.png`
|
||
exportFile.href = imageUrl
|
||
document.body.appendChild(exportFile)
|
||
exportFile.click()
|
||
document.body.removeChild(exportFile)
|
||
|
||
ElMessage.success('趋势图图片下载成功')
|
||
}
|
||
|
||
const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
|
||
if (isTrendToolDisabled(action)) 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 'wheel-zoom':
|
||
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
||
break
|
||
case 'missing-data':
|
||
missingDataEnabled.value = !missingDataEnabled.value
|
||
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 'fullscreen':
|
||
fullscreenVisible.value = true
|
||
break
|
||
case 'download-image':
|
||
await downloadSteadyTrendImage()
|
||
break
|
||
case 'query-data':
|
||
if (!hasSeries.value) {
|
||
ElMessage.warning('请先查询趋势数据')
|
||
break
|
||
}
|
||
if (isLargeTrendDataset.value) {
|
||
ElMessage.warning('当前趋势点数较大,请使用后台分页查询或导出接口查看明细数据')
|
||
break
|
||
}
|
||
dataTableVisible.value = true
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||
trendXZoomRange.value = {
|
||
start: clampPercent(value.start),
|
||
end: clampPercent(value.end)
|
||
}
|
||
|
||
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
|
||
activeTrendInteractionMode.value = 'none'
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.trendResult,
|
||
() => {
|
||
// 新查询结果按当前数据量重置默认窗口,避免沿用上一批数据的局部缩放范围。
|
||
defaultTrendXZoomRange.value = resolveSteadyTrendDefaultZoomRange(props.trendResult)
|
||
resetTrendToolState()
|
||
}
|
||
)
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.trend-chart-panel {
|
||
--steady-trend-chart-gap: 8px;
|
||
--steady-trend-visible-chart-count: 3;
|
||
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
padding: 12px;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
flex: none;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 0;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.panel-meta {
|
||
margin-left: 15px;
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chart-panel-body {
|
||
display: flex;
|
||
flex: 1 1 auto;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.chart-list {
|
||
display: flex;
|
||
flex: 1 1 auto;
|
||
flex-direction: column;
|
||
gap: var(--steady-trend-chart-gap);
|
||
min-height: 0;
|
||
overflow-x: hidden;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.chart-group {
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex: 0 0
|
||
calc(
|
||
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||
var(--steady-trend-visible-chart-count)
|
||
);
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 4px;
|
||
background: var(--cn-color-canvas-bg);
|
||
}
|
||
|
||
.chart-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
padding: 0 8px 8px;
|
||
}
|
||
|
||
.chart-empty {
|
||
flex: 1;
|
||
}
|
||
</style>
|