|
|
@@ -1,315 +1,518 @@
|
|
|
<template>
|
|
|
- <div class="realtime-curve-container">
|
|
|
- <!-- ECharts 容器 -->
|
|
|
- <div ref="chartRef" class="chart-box"></div>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
-
|
|
|
- <script setup lang="ts">
|
|
|
- // 补全所有必要导入,并严格指定类型
|
|
|
- import { ref, reactive, onMounted, onUnmounted, watch } from 'vue';
|
|
|
- import * as echarts from 'echarts';
|
|
|
- import type { ECharts, EChartsOption, YAxisOption, SeriesOption } from 'echarts';
|
|
|
- import BizVarDictAPI, { BizVarDictTable, BizVarDictPageQuery } from '@/api/module_business/vardict';
|
|
|
- import MqttUtil, { MqttMessageCallback } from '@/utils/mqttUtil';
|
|
|
-
|
|
|
- // ========== 严格 TS 类型定义 ==========
|
|
|
- /** MQTT 消息 payload 数据项类型 */
|
|
|
- interface MqttPayloadItem {
|
|
|
- var_code: string;
|
|
|
- value: number | string;
|
|
|
- [key: string]: unknown; // 兼容其他字段
|
|
|
+ <div class="realtimeCurve-content">
|
|
|
+ <div v-loading="loading" class="echarts-content">
|
|
|
+ <div ref="chartRef" class="echarts-container" :style="{ height: height }"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import type { ECharts, EChartsOption } from 'echarts';
|
|
|
+import BizVarDictAPI, { BizVarDictTable, BizVarDictPageQuery } from '@/api/module_business/vardict';
|
|
|
+import MqttUtil, { MqttMessageCallback } from '@/utils/mqttUtil';
|
|
|
+
|
|
|
+// 起重机信息
|
|
|
+const craneInfo = JSON.parse(localStorage.getItem('craneInfo') || '{}');
|
|
|
+const loading = ref(true);
|
|
|
+const height = ref('0px');
|
|
|
+
|
|
|
+// 查询参数
|
|
|
+const queryFormVarDictData = reactive<BizVarDictPageQuery>({
|
|
|
+ page_no: 0,
|
|
|
+ page_size: 0,
|
|
|
+ crane_no: craneInfo.crane_no,
|
|
|
+ var_code: undefined,
|
|
|
+ var_name: undefined,
|
|
|
+ mec_type: undefined,
|
|
|
+ switch_type: undefined,
|
|
|
+ gateway_id: undefined,
|
|
|
+ var_group: undefined,
|
|
|
+ var_category: undefined,
|
|
|
+ is_top_show: undefined,
|
|
|
+ is_save: undefined,
|
|
|
+ is_overview_top_show: undefined,
|
|
|
+ is_home_page_show: undefined,
|
|
|
+ status: undefined,
|
|
|
+ created_time: undefined,
|
|
|
+ updated_time: undefined,
|
|
|
+ created_id: undefined,
|
|
|
+ updated_id: undefined,
|
|
|
+});
|
|
|
+
|
|
|
+// MQTT配置
|
|
|
+const mqttConfig = {
|
|
|
+ wsUrl: import.meta.env.VITE_APP_WS_ENDPOINT || 'ws://127.0.0.1:9001',
|
|
|
+ topics: ['cdc/' + craneInfo.crane_no + '/analog/batch/#'],
|
|
|
+};
|
|
|
+const mqttUtil = new MqttUtil(mqttConfig);
|
|
|
+
|
|
|
+// 变量数据存储
|
|
|
+const allData = ref<BizVarDictTable[]>([]);
|
|
|
+
|
|
|
+// ========== ECharts核心配置 ==========
|
|
|
+const chartRef = ref<HTMLDivElement | null>(null);
|
|
|
+let chartInstance: ECharts | null = null;
|
|
|
+const MAX_DATA_POINTS = 100; // 单条曲线最大数据点
|
|
|
+const gridHeight = 150; // 每组Grid高度(px)
|
|
|
+const gridGap = 50; // 组间间距,保持宽松不拥挤
|
|
|
+const gridTopBase = 80; // 增大顶部基础间距,避免图例遮挡第一组
|
|
|
+
|
|
|
+// 全局X轴时间范围(关键:统一所有Grid的X轴范围)
|
|
|
+const xAxisRange = ref<{ min: number; max: number }>({
|
|
|
+ min: Date.now(), // 【修改1】初始无数据时设为当前时间,避免5分钟空白
|
|
|
+ max: Date.now()
|
|
|
+});
|
|
|
+
|
|
|
+// 单条曲线数据结构:[时间戳, 值]
|
|
|
+interface SeriesDataItem {
|
|
|
+ name: string; // 变量名(var_name)
|
|
|
+ code: string; // 变量编码
|
|
|
+ data: [number, number][]; // 时间戳+值
|
|
|
+}
|
|
|
+const seriesData = ref<SeriesDataItem[]>([]);
|
|
|
+
|
|
|
+// ========== 数据加载 ==========
|
|
|
+const getData = async () => {
|
|
|
+ const response = await BizVarDictAPI.listBizVarDictAnalog(queryFormVarDictData);
|
|
|
+ allData.value = response.data.data || [];
|
|
|
+ // 初始化多grid+多曲线
|
|
|
+ initMultiGridSeries();
|
|
|
+};
|
|
|
+
|
|
|
+// ========== 多Grid+多曲线初始化 ==========
|
|
|
+const initMultiGridSeries = () => {
|
|
|
+ if (allData.value.length === 0) {
|
|
|
+ console.warn('无变量数据,无法生成曲线');
|
|
|
+ return;
|
|
|
}
|
|
|
-
|
|
|
- /** MQTT 消息 payload 类型 */
|
|
|
- interface MqttPayload {
|
|
|
- data: MqttPayloadItem[];
|
|
|
- [key: string]: unknown;
|
|
|
- }
|
|
|
-
|
|
|
- /** 曲线数据结构类型 */
|
|
|
- interface CurveDataItem {
|
|
|
- xData: string[]; // X轴:时间戳字符串
|
|
|
- yData: number[]; // Y轴:数值
|
|
|
- }
|
|
|
-
|
|
|
- /** 曲线数据映射类型 */
|
|
|
- type CurveDataMap = Record<string, CurveDataItem>;
|
|
|
-
|
|
|
- // ========== 业务配置 ==========
|
|
|
- const craneInfo = JSON.parse(localStorage.getItem('craneInfo') || '{}') as Record<string, string>;
|
|
|
- const loading = ref<boolean>(true);
|
|
|
- // 严格指定查询参数类型,避免 undefined 隐式类型
|
|
|
- const queryFormVarDictData = reactive<BizVarDictPageQuery>({
|
|
|
- page_no: 0,
|
|
|
- page_size: 0,
|
|
|
- crane_no: craneInfo.crane_no || '', // 兜底空字符串,避免 TS 报错
|
|
|
- var_code: undefined,
|
|
|
- var_name: undefined,
|
|
|
- mec_type: undefined,
|
|
|
- switch_type: undefined,
|
|
|
- gateway_id: undefined,
|
|
|
- var_group: undefined,
|
|
|
- var_category: undefined,
|
|
|
- is_top_show: undefined,
|
|
|
- is_save: undefined,
|
|
|
- is_overview_top_show: undefined,
|
|
|
- is_home_page_show: undefined,
|
|
|
- status: undefined,
|
|
|
- created_time: undefined,
|
|
|
- updated_time: undefined,
|
|
|
- created_id: undefined,
|
|
|
- updated_id: undefined,
|
|
|
+
|
|
|
+ // 为每个变量初始化曲线数据(初始值+当前时间戳)
|
|
|
+ seriesData.value = allData.value.map(item => ({
|
|
|
+ name: item.var_name || '未知变量',
|
|
|
+ code: item.var_code || '',
|
|
|
+ data: [], // 初始数据点
|
|
|
+ }));
|
|
|
+ // 确保动态高度赋值完成,DOM渲染就绪后再初始化ECharts
|
|
|
+ nextTick(() => {
|
|
|
+ initEchartsWithMultiGrid();
|
|
|
});
|
|
|
-
|
|
|
- // MQTT 配置(TS 类型约束)
|
|
|
- const mqttConfig = {
|
|
|
- wsUrl: import.meta.env.VITE_APP_WS_ENDPOINT || 'ws://127.0.0.1:9001',
|
|
|
- topics: [`cdc/${craneInfo.crane_no || ''}/analog/batch/#`], // 兜底空字符串
|
|
|
- };
|
|
|
- const mqttUtil = new MqttUtil(mqttConfig);
|
|
|
-
|
|
|
- // ========== 核心数据(严格 TS 类型) ==========
|
|
|
- const allData = ref<BizVarDictTable[]>([]);
|
|
|
- const chartRef = ref<HTMLDivElement | null>(null);
|
|
|
- let chartInstance: ECharts | null = null;
|
|
|
- const curveData = ref<CurveDataMap>({});
|
|
|
- const MAX_DATA_LEN = 30; // 最大数据点数量
|
|
|
-
|
|
|
- // ========== 解决 ECharts 报错核心:规范 Y 轴/系列配置 ==========
|
|
|
- const initECharts = (): void => {
|
|
|
- if (!chartRef.value) return;
|
|
|
-
|
|
|
- // 销毁旧实例,避免重复创建
|
|
|
- if (chartInstance) {
|
|
|
- chartInstance.dispose();
|
|
|
- }
|
|
|
-
|
|
|
- // 初始化 ECharts 实例(指定 DOM 类型)
|
|
|
- chartInstance = echarts.init(chartRef.value);
|
|
|
-
|
|
|
- // 基础配置:无标题、图例置顶(规避 getAxesOnZeroOf 报错的核心:Y轴先初始化单轴,后续动态更新)
|
|
|
- const baseOption: EChartsOption = {
|
|
|
- title: { show: false }, // 隐藏标题
|
|
|
- legend: {
|
|
|
- top: 0, // 图例置顶
|
|
|
- left: 'center',
|
|
|
- textStyle: { fontSize: 12 },
|
|
|
- type: 'scroll', // 图例过多时滚动,避免溢出
|
|
|
- },
|
|
|
- tooltip: {
|
|
|
- trigger: 'axis',
|
|
|
- axisPointer: { type: 'line' }, // 改用 line 类型,避免 shadow 兼容性问题
|
|
|
- formatter: (params: echarts.EChartOption.TooltipFormatterParams) => {
|
|
|
- if (!Array.isArray(params) || params.length === 0) return '';
|
|
|
- const timeStr = new Date(params[0].name).toLocaleTimeString();
|
|
|
- return params.reduce((res, param) => {
|
|
|
- const value = param.value as number;
|
|
|
- return `${res}${param.seriesName}: ${value.toFixed(2)}<br/>`;
|
|
|
- }, `${timeStr}<br/>`);
|
|
|
- },
|
|
|
- },
|
|
|
- grid: {
|
|
|
- left: '10%',
|
|
|
- right: '10%',
|
|
|
- bottom: '15%',
|
|
|
- top: '15%',
|
|
|
- containLabel: true,
|
|
|
- },
|
|
|
- xAxis: {
|
|
|
- type: 'category',
|
|
|
- data: [],
|
|
|
- axisLabel: {
|
|
|
- formatter: (value: string) => new Date(value).toLocaleTimeString(),
|
|
|
- },
|
|
|
- boundaryGap: false, // 取消边界间隙,曲线更连续
|
|
|
+};
|
|
|
+
|
|
|
+// ========== 多Grid布局的ECharts初始化 ==========
|
|
|
+const initEchartsWithMultiGrid = () => {
|
|
|
+ if (!chartRef.value || !seriesData.value.length) return;
|
|
|
+
|
|
|
+ // 创建ECharts实例
|
|
|
+ chartInstance = echarts.init(chartRef.value);
|
|
|
+
|
|
|
+ // 计算每个Grid的尺寸:核心优化确保任意数量数据完整显示
|
|
|
+ const gridCount = seriesData.value.length; // 取allData实际数量(十几条也支持)
|
|
|
+
|
|
|
+ // 动态计算高度:根据allData数量计算总高度,赋值给height ref
|
|
|
+ const totalChartHeight = gridTopBase + gridCount * (gridHeight + gridGap);
|
|
|
+ height.value = `${totalChartHeight}px`; // 绑定到容器style的height属性
|
|
|
+
|
|
|
+ // 1. 构建多Grid配置
|
|
|
+ const grids = seriesData.value.map((_, index) => ({
|
|
|
+ id: `grid_${index}`,
|
|
|
+ left: '3%', // 【修改2】Grid左间距设为0,消除视觉上的左侧空白
|
|
|
+ right: '3%', // 【修改2】Grid右间距设为0,消除视觉上的右侧空白
|
|
|
+ top: gridTopBase + index * (gridHeight + gridGap),
|
|
|
+ height: gridHeight,
|
|
|
+ width: '96%', // 【修改2】Grid宽度设为100%,填满容器
|
|
|
+ containLabel: true,
|
|
|
+ padding: [10, 20, 20, 20], // 微调内边距,避免标签溢出
|
|
|
+ show: true
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 2. 构建多X轴配置(统一时间范围 + 保留白色大字体 + 仅保留横线(竖线关闭))
|
|
|
+ const xAxes: any[] = seriesData.value.map((_, index) => ({
|
|
|
+ gridIndex: index,
|
|
|
+ type: 'time',
|
|
|
+ show: true,
|
|
|
+ // 关键:所有X轴使用统一的时间范围
|
|
|
+ min: xAxisRange.value.min,
|
|
|
+ max: xAxisRange.value.max,
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (value: number) => new Date(value).toLocaleTimeString(),
|
|
|
+ fontSize: 14,
|
|
|
+ color: '#506388',
|
|
|
+ margin: 10
|
|
|
+ },
|
|
|
+ // 关闭X轴分割线,避免视觉干扰
|
|
|
+ splitLine: { show: false },
|
|
|
+ // 强制X轴刻度对齐
|
|
|
+ alignTicks: true,
|
|
|
+ boundaryGap: false, // 取消边界间隙,确保时间轴完全对齐
|
|
|
+ axisLine: {
|
|
|
+ onZero: false // 【修改3】关闭轴线对齐0点,避免额外留白
|
|
|
+ },
|
|
|
+ scale: false // 【修改3】禁用自动缩放,严格按min/max显示
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 3. 构建多Y轴配置(保留白色大字体 + 恢复灰色细横线(仅横线))
|
|
|
+ const yAxes: any[] = seriesData.value.map((item, index) => ({
|
|
|
+ gridIndex: index,
|
|
|
+ type: 'value',
|
|
|
+ show: true,
|
|
|
+ name: item.name,
|
|
|
+ nameTextStyle: {
|
|
|
+ fontSize: 14, // 字号稍大
|
|
|
+ color: '#fff' // y轴名称白色字体
|
|
|
+ },
|
|
|
+ nameGap: 20,
|
|
|
+ axisLabel: {
|
|
|
+ formatter: '{value}',
|
|
|
+ fontSize: 14, // 字号稍大
|
|
|
+ color: '#506388', // 刻度值字体
|
|
|
+ margin: 30
|
|
|
+ },
|
|
|
+ // 水平网格线
|
|
|
+ splitLine: {
|
|
|
+ show: true, // 开启水平网格线(横线)
|
|
|
+ lineStyle: {
|
|
|
+ color: '#3E5487', // 灰色(与深色背景协调)
|
|
|
+ width: 1 // 柔和不突兀,避免太扎眼
|
|
|
+ }
|
|
|
+ },
|
|
|
+ position: 'left' // 恢复y轴左侧布局
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 4. 构建多Series配置(确保曲线颜色和图例颜色对照,支持任意数据量)
|
|
|
+ const series: any[] = seriesData.value.map((item, index) => {
|
|
|
+ const curveColor = getColor(index); // 调用优化后的颜色函数,支持任意index
|
|
|
+ return {
|
|
|
+ name: item.name,
|
|
|
+ type: 'line',
|
|
|
+ smooth: true,
|
|
|
+ symbol: 'none',
|
|
|
+ gridIndex: index,
|
|
|
+ xAxisIndex: index,
|
|
|
+ yAxisIndex: index,
|
|
|
+ data: item.data,
|
|
|
+ color: curveColor, // 明确设置series颜色(图例将自动取该颜色)
|
|
|
+ lineStyle: { color: curveColor, width: 2 }, // 曲线颜色与series颜色一致
|
|
|
+ itemStyle: { color: curveColor }, // 图例标记颜色与曲线一致
|
|
|
+ areaStyle: {
|
|
|
+ // 优化面积图颜色,增加渐变和透明度,适配动态颜色
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: curveColor + 'cc' }, // 顶部半透明
|
|
|
+ { offset: 1, color: curveColor + '11' } // 底部几乎透明
|
|
|
+ ])
|
|
|
+ }
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // ECharts最终配置
|
|
|
+ const option: EChartsOption = {
|
|
|
+ // 【核心配置1】全局开启刻度对齐
|
|
|
+ alignTicks: true,
|
|
|
+ // 图例配置(确保与曲线颜色对照,支持任意数据量)
|
|
|
+ legend: {
|
|
|
+ show: true,
|
|
|
+ top: 20,
|
|
|
+ left: 'center',
|
|
|
+ data: seriesData.value.map(item => item.name),
|
|
|
+ textStyle: {
|
|
|
+ fontSize: 14,
|
|
|
+ color: '#fff' // 图例文字白色字体
|
|
|
},
|
|
|
- // 初始单 Y 轴(解决 getAxesOnZeroOf 报错),后续按变量值域分组分配区间
|
|
|
- yAxis: {
|
|
|
- type: 'value',
|
|
|
- axisLine: { show: true },
|
|
|
- splitLine: { show: true },
|
|
|
+ itemWidth: 12,
|
|
|
+ itemHeight: 12,
|
|
|
+ padding: [0, 20],
|
|
|
+ formatter: (name) => {
|
|
|
+ return name.length > 10 ? name.substring(0, 10) + '...' : name;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // Tooltip配置
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ triggerOn: 'mousemove',
|
|
|
+ formatter: function (params: any) {
|
|
|
+ if (!params || params.length === 0) return '';
|
|
|
+
|
|
|
+ const targetTime = params[0].data[0];
|
|
|
+ const timeStr = new Date(targetTime).toLocaleTimeString();
|
|
|
+ let tooltipHtml = `<div style="font-weight: bold; margin-bottom: 8px;">时间:${timeStr}</div>`;
|
|
|
+
|
|
|
+ seriesData.value.forEach((seriesItem, idx) => {
|
|
|
+ const closestData = seriesItem.data.find(d => d[0] === targetTime);
|
|
|
+ const value = closestData ? closestData[1] : '-';
|
|
|
+ tooltipHtml += `
|
|
|
+ <div style="display: flex; align-items: center; margin: 4px 0;">
|
|
|
+ <span style="display: inline-block; width: 8px; height: 8px; background: ${getColor(idx)}; margin-right: 8px; border-radius: 50%;"></span>
|
|
|
+ <span>${seriesItem.name}:</span>
|
|
|
+ <span style="margin-left: 8px; font-family: monospace;">${value}</span>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ });
|
|
|
+ return tooltipHtml;
|
|
|
},
|
|
|
- series: [], // 动态生成系列
|
|
|
- };
|
|
|
-
|
|
|
- chartInstance.setOption(baseOption);
|
|
|
-
|
|
|
- // 监听窗口大小变化
|
|
|
- window.addEventListener('resize', handleResize);
|
|
|
- };
|
|
|
-
|
|
|
- /** 窗口大小变化处理 */
|
|
|
- const handleResize = (): void => {
|
|
|
- chartInstance?.resize();
|
|
|
- };
|
|
|
-
|
|
|
- /** 初始化曲线数据 */
|
|
|
- const initCurveData = (): void => {
|
|
|
- if (!allData.value.length) return;
|
|
|
-
|
|
|
- // 初始化每个变量的曲线数据
|
|
|
- curveData.value = allData.value.reduce((map, item) => {
|
|
|
- map[item.var_code] = { xData: [], yData: [] };
|
|
|
- return map;
|
|
|
- }, {} as CurveDataMap);
|
|
|
-
|
|
|
- // 初始化系列配置(曲线不交叉核心:按变量值域分配不同区间)
|
|
|
- updateEChartsSeries();
|
|
|
+ backgroundColor: 'rgba(255,255,255,0.95)',
|
|
|
+ borderColor: '#e6e6e6',
|
|
|
+ borderWidth: 1,
|
|
|
+ padding: 12,
|
|
|
+ textStyle: { fontSize: 12 },
|
|
|
+ },
|
|
|
+ height: totalChartHeight,
|
|
|
+ grid: grids,
|
|
|
+ xAxis: xAxes,
|
|
|
+ yAxis: yAxes,
|
|
|
+ series: series,
|
|
|
};
|
|
|
-
|
|
|
- /** 更新 ECharts 系列配置(规避曲线交叉:值域分段) */
|
|
|
- const updateEChartsSeries = (): void => {
|
|
|
- if (!chartInstance || !allData.value.length) return;
|
|
|
-
|
|
|
- // 为每个变量分配独立值域区间(避免交叉)
|
|
|
- const seriesList: SeriesOption[] = allData.value.map((item, index) => {
|
|
|
- // 按索引分配值域区间(如第1个变量[0-100],第2个[100-200],以此类推)
|
|
|
- const valueRange = index * 100;
|
|
|
-
|
|
|
+
|
|
|
+ chartInstance.setOption(option);
|
|
|
+
|
|
|
+ // 【监听图例勾选变化事件】
|
|
|
+ chartInstance.off('legendselectchanged');
|
|
|
+ chartInstance.on('legendselectchanged', (params: any) => {
|
|
|
+ if (!params || !params.selected) {
|
|
|
+ console.warn('无有效勾选状态数据');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const selectedLegend = params.selected;
|
|
|
+
|
|
|
+ // 【核心步骤1:筛选出「显示状态」的图例名称和对应的索引】
|
|
|
+ const showSeriesNames = Object.entries(selectedLegend)
|
|
|
+ .filter(([_, isSelected]) => isSelected)
|
|
|
+ .map(([name]) => name);
|
|
|
+ // 得到显示的索引数组(按原有顺序排列)
|
|
|
+ const showIndices = seriesData.value
|
|
|
+ .map((item, index) => ({ name: item.name, index }))
|
|
|
+ .filter((item) => showSeriesNames.includes(item.name))
|
|
|
+ .map((item) => item.index);
|
|
|
+
|
|
|
+ // 【核心步骤2:遍历所有grid,更新show属性 + 重新计算显示grid的top值】
|
|
|
+ const updatedGrids = grids.map((grid, index) => {
|
|
|
+ const seriesName = seriesData.value[index].name;
|
|
|
+ const isShow = selectedLegend[seriesName] || false;
|
|
|
+
|
|
|
+ if (!isShow) {
|
|
|
+ // 隐藏的grid:仅设置show: false,保持其他属性不变
|
|
|
+ return { ...grid, show: false };
|
|
|
+ } else {
|
|
|
+ // 显示的grid:找到它在「显示数组」中的排序,重新计算top值
|
|
|
+ const showSortIndex = showIndices.findIndex(showIndex => showIndex === index);
|
|
|
+ const newTop = gridTopBase + showSortIndex * (gridHeight + gridGap);
|
|
|
+ return { ...grid, show: true, top: newTop }; // 应用新的top值,实现紧凑排列
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 【原有逻辑:更新xAxis/yAxis的show属性 + 同步X轴范围】
|
|
|
+ const updatedXAxes = xAxes.map((xAxis, index) => {
|
|
|
+ const seriesName = seriesData.value[index].name;
|
|
|
return {
|
|
|
- name: item.var_name || item.var_code,
|
|
|
- type: 'line',
|
|
|
- data: curveData.value[item.var_code]?.yData || [],
|
|
|
- symbol: 'none',
|
|
|
- smooth: true,
|
|
|
- lineStyle: { width: 2 },
|
|
|
- // 值域映射:将原始值映射到独立区间,避免交叉
|
|
|
- encode: { y: 0 },
|
|
|
- // 自定义数值转换(核心:曲线不交叉)
|
|
|
- transform: [
|
|
|
- {
|
|
|
- type: 'ecSimpleTransform:custom',
|
|
|
- config: {
|
|
|
- callback: (params: { value: number }) => {
|
|
|
- // 原始值 + 区间偏移量,保证每条曲线值域不重叠
|
|
|
- return { value: params.value + valueRange };
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- ],
|
|
|
+ ...xAxis,
|
|
|
+ show: selectedLegend[seriesName] || false,
|
|
|
+ min: xAxisRange.value.min, // 保持X轴范围统一
|
|
|
+ max: xAxisRange.value.max, // 保持X轴范围统一
|
|
|
+ boundaryGap: false, // 【修改4】图例切换时同步保留无留白配置
|
|
|
+ axisLine: { onZero: false },
|
|
|
+ scale: false
|
|
|
};
|
|
|
});
|
|
|
-
|
|
|
- // 更新系列配置(避免直接修改 Y 轴导致 getAxesOnZeroOf 报错)
|
|
|
- chartInstance.setOption({
|
|
|
- series: seriesList,
|
|
|
+
|
|
|
+ const updatedYAxes = yAxes.map((yAxis, index) => {
|
|
|
+ const seriesName = seriesData.value[index].name;
|
|
|
+ return { ...yAxis, show: selectedLegend[seriesName] || false };
|
|
|
});
|
|
|
- };
|
|
|
-
|
|
|
- /** 更新曲线数据(严格 TS 类型) */
|
|
|
- const updateCurveData = (varCode: string, value: number): void => {
|
|
|
- if (!curveData.value[varCode]) return;
|
|
|
-
|
|
|
- const now = new Date().toISOString();
|
|
|
- const curveItem = curveData.value[varCode];
|
|
|
-
|
|
|
- // 添加新数据
|
|
|
- curveItem.xData.push(now);
|
|
|
- curveItem.yData.push(Number(value));
|
|
|
-
|
|
|
- // 限制数据长度
|
|
|
- if (curveItem.xData.length > MAX_DATA_LEN) {
|
|
|
- curveItem.xData.shift();
|
|
|
- curveItem.yData.shift();
|
|
|
- }
|
|
|
-
|
|
|
- // 更新 ECharts(仅更新数据,不修改轴配置,规避报错)
|
|
|
+
|
|
|
+ // 【原有逻辑:重新计算图表总高度】
|
|
|
+ const showGridCount = showIndices.length;
|
|
|
+ const newTotalChartHeight = gridTopBase + showGridCount * (gridHeight + gridGap);
|
|
|
+ height.value = `${newTotalChartHeight}px`;
|
|
|
+
|
|
|
+ // 【核心步骤3:更新ECharts配置,应用新的grid位置和尺寸】
|
|
|
+ chartInstance?.setOption({
|
|
|
+ grid: updatedGrids,
|
|
|
+ xAxis: updatedXAxes,
|
|
|
+ yAxis: updatedYAxes,
|
|
|
+ height: newTotalChartHeight
|
|
|
+ });
|
|
|
+
|
|
|
+ // 【原有逻辑:重新调整图表尺寸】
|
|
|
+ chartInstance?.resize({
|
|
|
+ width: 'auto', // 精准获取容器实际宽度
|
|
|
+ height: newTotalChartHeight
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 初始化后主动调用resize,强制适配容器尺寸,解决不显示问题
|
|
|
+ chartInstance.resize({
|
|
|
+ width: 'auto',
|
|
|
+ height: totalChartHeight,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 窗口自适应 + 滚动容器适配
|
|
|
+ const resizeHandler = () => {
|
|
|
if (chartInstance) {
|
|
|
- const seriesOption = chartInstance.getOption().series as SeriesOption[];
|
|
|
- const targetSeriesIndex = allData.value.findIndex(item => item.var_code === varCode);
|
|
|
-
|
|
|
- if (targetSeriesIndex > -1) {
|
|
|
- chartInstance.setOption({
|
|
|
- xAxis: { data: curveItem.xData },
|
|
|
- series: [
|
|
|
- {
|
|
|
- index: targetSeriesIndex,
|
|
|
- data: curveItem.yData,
|
|
|
- },
|
|
|
- ],
|
|
|
- });
|
|
|
- }
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- // ========== 数据获取 & MQTT 处理(严格 TS 类型) ==========
|
|
|
- const getData = async (): Promise<void> => {
|
|
|
- try {
|
|
|
- const response = await BizVarDictAPI.listBizVarDictAnalog(queryFormVarDictData);
|
|
|
- allData.value = response.data.data || [];
|
|
|
- initCurveData();
|
|
|
- } catch (error) {
|
|
|
- console.error('获取变量数据失败:', error);
|
|
|
- } finally {
|
|
|
- loading.value = false;
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- /** MQTT 消息处理(严格 TS 类型,兼容 analog/alarm 后缀) */
|
|
|
- const handleMqttMessage: MqttMessageCallback = (topic: string, payload: MqttPayload): void => {
|
|
|
- const topicLevels = topic.split('/');
|
|
|
- if (topicLevels.length < 4 || !payload.data) return;
|
|
|
-
|
|
|
- const suffix = topicLevels[2];
|
|
|
- // 兼容 analog(实时数据)和 alarm(告警数据)
|
|
|
- if (['analog', 'alarm'].includes(suffix)) {
|
|
|
- payload.data.forEach((payloadItem) => {
|
|
|
- const { var_code, value } = payloadItem;
|
|
|
- if (!var_code || value === undefined) return;
|
|
|
-
|
|
|
- // 更新 allData 中的值
|
|
|
- const targetItem = allData.value.find(item => item.var_code === var_code);
|
|
|
- if (targetItem) {
|
|
|
- targetItem.value = Number(value);
|
|
|
- }
|
|
|
-
|
|
|
- // 更新曲线数据(仅处理数值类型)
|
|
|
- const numValue = Number(value);
|
|
|
- if (!isNaN(numValue)) {
|
|
|
- updateCurveData(var_code, numValue);
|
|
|
- }
|
|
|
+ chartInstance.resize({
|
|
|
+ width: 'auto',
|
|
|
+ height: 'auto',
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
-
|
|
|
- // ========== 生命周期(严格 TS 规范) ==========
|
|
|
- onMounted(async (): Promise<void> => {
|
|
|
- initECharts();
|
|
|
- await getData();
|
|
|
- mqttUtil.initConnect(handleMqttMessage);
|
|
|
+ window.addEventListener('resize', resizeHandler);
|
|
|
+};
|
|
|
+
|
|
|
+// ========== 更新全局X轴时间范围(关键:保证所有曲线水平对齐) ==========
|
|
|
+const updateXAxisRange = () => {
|
|
|
+ // 收集所有曲线的时间戳
|
|
|
+ const allTimestamps: number[] = [];
|
|
|
+ seriesData.value.forEach(item => {
|
|
|
+ item.data.forEach(([timestamp]) => allTimestamps.push(timestamp));
|
|
|
});
|
|
|
-
|
|
|
- onUnmounted((): void => {
|
|
|
- mqttUtil.releaseResources();
|
|
|
- window.removeEventListener('resize', handleResize);
|
|
|
- // 销毁 ECharts 实例,避免内存泄漏
|
|
|
- if (chartInstance) {
|
|
|
- chartInstance.dispose();
|
|
|
- chartInstance = null;
|
|
|
+
|
|
|
+ if (allTimestamps.length === 0) {
|
|
|
+ // 无数据时设为当前时间(无空白)
|
|
|
+ xAxisRange.value = {
|
|
|
+ min: Date.now(),
|
|
|
+ max: Date.now()
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ // 【修改5:移除±1000的缓冲,严格使用数据的最小/最大时间】
|
|
|
+ const minTime = Math.min(...allTimestamps);
|
|
|
+ const maxTime = Math.max(...allTimestamps);
|
|
|
+ xAxisRange.value = {
|
|
|
+ min: minTime,
|
|
|
+ max: maxTime
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// ========== MQTT回调(实时更新多Grid曲线) ==========
|
|
|
+const handleMqttMessage: MqttMessageCallback = (topic, payload) => {
|
|
|
+ if (!payload?.data) return;
|
|
|
+
|
|
|
+ // 遍历MQTT数据,更新对应变量的曲线
|
|
|
+ payload.data.forEach((payloadItem: any) => {
|
|
|
+ if (!payloadItem.var_code) return;
|
|
|
+
|
|
|
+ // 找到对应变量的曲线数据
|
|
|
+ const targetSeries = seriesData.value.find(item => item.code === payloadItem.var_code);
|
|
|
+ if (targetSeries) {
|
|
|
+ // 添加新数据点(当前时间戳 + 最新值)
|
|
|
+ targetSeries.data.push([payload.timestamp, payloadItem.value]);
|
|
|
+ // 超出最大数据点时,删除最早的点
|
|
|
+ if (targetSeries.data.length > MAX_DATA_POINTS) {
|
|
|
+ targetSeries.data.shift();
|
|
|
+ }
|
|
|
}
|
|
|
});
|
|
|
-
|
|
|
- // 深度监听 allData 变化
|
|
|
- watch(
|
|
|
- allData,
|
|
|
- () => {
|
|
|
- if (!loading.value) {
|
|
|
- initCurveData();
|
|
|
+
|
|
|
+ // 关键:更新全局X轴范围,确保所有曲线时间轴对齐
|
|
|
+ updateXAxisRange();
|
|
|
+ // 实时更新图表
|
|
|
+ updateEcharts();
|
|
|
+ loading.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// ========== 图表更新方法(同步X轴范围) ==========
|
|
|
+const updateEcharts = () => {
|
|
|
+ if (!chartInstance || !seriesData.value.length) return;
|
|
|
+
|
|
|
+ // 1. 更新所有X轴的时间范围(同步无留白配置)
|
|
|
+ const updatedXAxes = seriesData.value.map((_, index) => ({
|
|
|
+ gridIndex: index,
|
|
|
+ min: xAxisRange.value.min,
|
|
|
+ max: xAxisRange.value.max,
|
|
|
+ boundaryGap: false, // 【修改6】数据更新时同步保留无留白配置
|
|
|
+ axisLine: { onZero: false },
|
|
|
+ scale: false
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 2. 更新series数据(避免重复渲染grid/x/y轴)
|
|
|
+ const updatedSeries = seriesData.value.map((item, index) => {
|
|
|
+ const curveColor = getColor(index);
|
|
|
+ return {
|
|
|
+ gridIndex: index,
|
|
|
+ xAxisIndex: index,
|
|
|
+ yAxisIndex: index,
|
|
|
+ data: item.data,
|
|
|
+ color: curveColor, // 保持更新时颜色一致
|
|
|
+ lineStyle: { color: curveColor, width: 2 },
|
|
|
+ areaStyle: {
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: curveColor + 'cc' },
|
|
|
+ { offset: 1, color: curveColor + '11' }
|
|
|
+ ])
|
|
|
}
|
|
|
- },
|
|
|
- { deep: true }
|
|
|
- );
|
|
|
- </script>
|
|
|
-
|
|
|
- <style lang="less" scoped>
|
|
|
- .realtime-curve-container {
|
|
|
- width: 100%;
|
|
|
- height: 100vh;
|
|
|
-
|
|
|
- .chart-box {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- }
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // 3. 应用更新(先更X轴范围,再更数据,确保对齐)
|
|
|
+ chartInstance.setOption({
|
|
|
+ xAxis: updatedXAxes,
|
|
|
+ series: updatedSeries
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ========== 优化:支持任意数量数据的颜色生成函数(不再写死) ==========
|
|
|
+/**
|
|
|
+ * 生成区分度高的颜色,支持任意数量的索引(十几条、几十条都支持)
|
|
|
+ */
|
|
|
+const getColor = (index: number) => {
|
|
|
+ const extendedColors = [
|
|
|
+ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57',
|
|
|
+ '#FF9FF3', '#54A0FF', '#8E44AD', '#E74C3C', '#3498DB',
|
|
|
+ '#2ECC71', '#F1C40F', '#9B59B6', '#1ABC9C', '#E67E22',
|
|
|
+ '#34495E', '#FF7675', '#74B9FF', '#00B894', '#FDCB6E'
|
|
|
+ ];
|
|
|
+ return extendedColors[index % extendedColors.length];
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ await getData();
|
|
|
+ mqttUtil.initConnect(handleMqttMessage);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ // 销毁ECharts实例
|
|
|
+ if (chartInstance) {
|
|
|
+ chartInstance.dispose();
|
|
|
+ chartInstance = null;
|
|
|
+ }
|
|
|
+ // 释放MQTT资源
|
|
|
+ mqttUtil.releaseResources();
|
|
|
+ // 移除resize监听
|
|
|
+ window.removeEventListener('resize', () => chartInstance?.resize());
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="less" scoped>
|
|
|
+.realtimeCurve-content {
|
|
|
+ :deep(.el-loading-parent--relative) {
|
|
|
+ --el-mask-color: rgba(255, 255, 255, 0.1);
|
|
|
}
|
|
|
- </style>
|
|
|
+}
|
|
|
+
|
|
|
+.echarts-content {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow-x: hidden;
|
|
|
+ background: linear-gradient(to bottom, rgba(20, 66, 140, 0.8) 0%, rgba(18, 31, 52, 1) 15%);
|
|
|
+}
|
|
|
+
|
|
|
+// ECharts容器:适配动态高度
|
|
|
+.echarts-container {
|
|
|
+ width: 100%;
|
|
|
+ // 高度由动态计算的height ref决定,无需额外设置
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar {
|
|
|
+ width: 5px;
|
|
|
+ height: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar-track {
|
|
|
+ border-radius: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar-thumb {
|
|
|
+ border-radius: 7px;
|
|
|
+ background-color: #798DAE;
|
|
|
+}
|
|
|
+</style>
|