Explorar o código

feat:站点监控页面添加SOC图表

Jerry Liang hai 1 mes
pai
achega
eb32ff104e

+ 137 - 90
src/views/sites-monitor/component/powerFlowChart.vue

@@ -3,12 +3,10 @@
 </template>
 
 <script setup lang="ts">
-import { Chart, ChartEvent, register } from '@antv/g2'
-import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { Chart, ChartEvent } from '@antv/g2'
+import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 import { usePersonalization } from '@/composable/usePersonalization'
 
-const message = useMessage()
-
 const { isDark } = usePersonalization()
 
 const props = defineProps<{
@@ -29,96 +27,123 @@ export interface PowerChartData {
   Power?: number
 }
 
+export interface SocChartData {
+  Time: string
+  Soc?: number
+}
+
 let isVoidData = true
-const voidData: PowerChartData[] = (() => {
-  const times: string[] = []
-  const totalMinutes = 24 * 60 // 一天 = 1440 分钟
+// 缓存最近一次数据,供主题切换时复用
+let lastPowerData: PowerChartData[] | undefined = undefined
+let lastSocData: SocChartData[] | undefined = undefined
 
+// 生成一天的时间刻度(每 5 分钟)
+const buildTimes = (): string[] => {
+  const times: string[] = []
+  const totalMinutes = 24 * 60
   for (let m = 0; m < totalMinutes; m += 5) {
     const hh = String(Math.floor(m / 60)).padStart(2, '0')
     const mm = String(m % 60).padStart(2, '0')
     times.push(`${hh}:${mm}`)
   }
+  return times
+}
 
-  return times.map((t) => {
-    return [
-      {
-        powerType: 'aPower',
-        Time: t,
-      },
+// 空功率数据
+const voidPowerData: PowerChartData[] = buildTimes()
+  .map((t) => [
+    { powerType: 'aPower', Time: t },
+    { powerType: 'Generator', Time: t },
+    { powerType: 'HomeLoad', Time: t },
+    { powerType: 'PV', Time: t },
+    { powerType: 'Grid', Time: t },
+  ])
+  .flat()
+
+// 空 SOC 数据
+const voidSocData: SocChartData[] = buildTimes().map((t) => ({ Time: t }))
+
+// 颜色映射(功率类型)
+const powerColorRelations: [string, string][] = [
+  ['aPower', '#41D187'],
+  ['Generator', '#5398FF'],
+  ['HomeLoad', '#0BF5FF'],
+  ['PV', '#F1A502'],
+  ['Grid', '#BB56FD'],
+]
+
+// 构建图表 options(上:功率堆叠面积;下:SOC 折线;共享 X 轴 Time)
+const buildOptions = (powerData: PowerChartData[], socData: SocChartData[]) => {
+  return {
+    type: 'spaceFlex' as const,
+    direction: 'col' as const,
+    ratio: [2, 1],
+    theme: { type: isDark.value ? 'classicDark' : 'classic' },
+    children: [
       {
-        powerType: 'Generator',
-        Time: t,
+        // 上图:功率堆叠面积
+        type: 'area' as const,
+        data: powerData,
+        // 与下图左内边距对齐,保证 X 轴严格对齐
+        paddingLeft: 60,
+        paddingBottom: 0,
+        // 压缩与下图的间隔,仅留一点缝隙
+        marginBottom: 6,
+        transform: [{ type: 'stackY' as const }],
+        encode: { x: 'Time', y: 'Power', color: 'powerType' },
+        scale: {
+          color: { palette: 'accent', relations: powerColorRelations },
+        },
+        axis: { x: false },
+        interaction: { elementHighlight: { background: true } },
       },
       {
-        powerType: 'HomeLoad',
-        Time: t,
+        // 下图:SOC 折线
+        type: 'line' as const,
+        data: socData,
+        paddingLeft: 60,
+        paddingTop: 0,
+        // 压缩与上图的间隔,仅留一点缝隙
+        marginTop: 6,
+        encode: { x: 'Time', y: 'Soc' },
+        scale: { y: { domain: [0, 100], nice: false } },
+        style: { stroke: '#F5A623', lineWidth: 2 },
+        axis: { y: { title: 'SOC (%)' } },
       },
-      {
-        powerType: 'PV',
-        Time: t,
-      },
-      {
-        powerType: 'Grid',
-        Time: t,
-      },
-    ]
-  }).flat()
-})()
-
-// 自定义色板
-// PV: #F1A502
-// GEN: #5398FF
-// Grid: #BB56FD
-// aPower: #41D187
-// Home: #0BF5FF
-// register('palette.customPowerFlow', customPalette);
-// function customPalette() {
-//   return ['#F1A502', '#5398FF', '#BB56FD', '#41D187', '#0BF5FF'];
-// }
+    ],
+  }
+}
+
+// 绑定图例交互事件(功率类型图例)
+const bindLegendEvents = () => {
+  if (chart === null) return
+
+  // 监听图例点击事件 - 当用户通过图例进行筛选时触发
+  chart.on('legend:filter', (e: any) => {
+    const { nativeEvent, data } = e
+    if (!nativeEvent) return
+    emit('changeLegend', data?.values ?? [])
+  })
+
+  // 监听图例点击事件 - 当所有图例项都被选中时触发(重置状态)
+  chart.on('legend:reset', (e: any) => {
+    const { nativeEvent } = e
+    if (!nativeEvent) return
+    emit('changeLegend', [])
+  })
+}
 
 onMounted(() => {
   chart = new Chart({
     container: chartContainer.value!,
     autoFit: true,
-    height: props.height || 300
-  });
-
-  chart
-    .area()
-    .theme({ type: isDark ? 'dark' : 'light' })
-    .data(voidData)
-    .encode('x', 'Time')
-    .encode('y', 'Power')
-    .encode('color', 'powerType')
-    .transform({ type: 'stackY' })
-    .interaction('elementHighlight', { background: true })
-    .scale('color', {
-      palette: 'accent',
-      relations: [
-        ['aPower', '#41D187'],
-        ['Generator', '#5398FF'],
-        ['HomeLoad', '#0BF5FF'],
-        ['PV', '#F1A502'],
-        ['Grid', '#BB56FD'],
-      ],
-    })
-
-  chart.render();
+    height: props.height || 300,
+  })
 
-  // 监听图例点击事件 - 当用户通过图例进行筛选时触发
-  chart.on('legend:filter', (e) => {
-    const { nativeEvent, data } = e;
-    if (!nativeEvent) return;
-    emit('changeLegend', data.values ?? [])
-  });
+  chart.options(buildOptions(voidPowerData, voidSocData))
+  chart.render()
 
-  // 监听图例点击事件 - 当所有图例项都被选中时触发(重置状态)
-  chart.on('legend:reset', (e) => {
-    const { nativeEvent } = e;
-    if (!nativeEvent) return;
-    emit('changeLegend', [])
-  });
+  bindLegendEvents()
 })
 
 onBeforeUnmount(() => {
@@ -126,21 +151,34 @@ onBeforeUnmount(() => {
 })
 
 // 重绘数据
-const redraw = (data: PowerChartData[] | undefined, legend?: string[]) => {
+const redraw = (
+  powerData: PowerChartData[] | undefined,
+  socData: SocChartData[] | undefined,
+  legend?: string[],
+) => {
   if (chart === null) return
 
+  // 渲染完成后恢复图例筛选状态
   if (legend !== undefined && legend.length > 0) {
-    chart.on(ChartEvent.AFTER_RENDER, () => {
-      changeLegend(legend)
-    }, true)
+    chart.on(
+      ChartEvent.AFTER_RENDER,
+      () => {
+        changeLegend(legend)
+      },
+      true,
+    )
   }
 
-  if (data === undefined) {
+  if (powerData === undefined) {
     if (!isVoidData) {
-      chart.changeData(voidData)
+      isVoidData = true
+      chart.options(buildOptions(voidPowerData, voidSocData))
+      chart.render()
     }
   } else {
-    chart.changeData(data)
+    isVoidData = false
+    chart.options(buildOptions(powerData, socData ?? voidSocData))
+    chart.render()
   }
 }
 
@@ -148,26 +186,35 @@ const redraw = (data: PowerChartData[] | undefined, legend?: string[]) => {
 const changeLegend = (legend: string[]): boolean => {
   if (chart === null) return false
   if (legend.length === 0) {
-    chart.emit('legend:reset', {});
+    chart.emit('legend:reset', {})
   } else {
     chart.emit('legend:filter', {
       data: { channel: 'color', values: [...legend] },
-    });
+    })
   }
   return true
 }
 
-watch(isDark, (newVal) => {
+watch(isDark, () => {
   if (chart) {
-    chart.theme({ type: newVal ? 'dark' : 'light' })
-    chart.render()
+    // 主题变化时重建 options 以应用新主题
+    redraw(lastPowerData, lastSocData)
   }
 })
 
+const redrawWithCache = (
+  powerData: PowerChartData[] | undefined,
+  socData: SocChartData[] | undefined,
+  legend?: string[],
+) => {
+  lastPowerData = powerData
+  lastSocData = socData
+  redraw(powerData, socData, legend)
+}
+
 // 暴露给父组件
 defineExpose({
-  redraw, // 重绘数据
-  changeLegend // 更新图例显示
+  redraw: redrawWithCache, // 重绘数据
+  changeLegend, // 更新图例显示
 })
-
 </script>

+ 23 - 2
src/views/sites-monitor/component/siteItem.vue

@@ -48,7 +48,7 @@
 </template>
 
 <script setup lang='ts'>
-import PowerFlowChart from './powerFlowChart.vue'
+import PowerFlowChart, { type SocChartData } from './powerFlowChart.vue'
 import { useSitesMonitorStateStore } from '@/stores/modules/views/sitesMonitorState'
 import { useSitesMonitorConfigStore } from '@/stores/modules/views/sitesMonitorConfig';
 import { useSiteInfoDataStore } from '@/stores/modules/data/siteInfoData';
@@ -144,7 +144,28 @@ const loadingPowerFlowData = async (sn: string, date: string, legend?: string[])
     await sitePowerFlowDataStore.queryPowerFlow(sn, date)
     powerData = sitePowerFlowDataStore.powerFlowChartGet(sn, date)
   }
-  powerFlowChartRef.value?.redraw(powerData, legend)
+
+  // 构造 SOC 折线数据(复用原始功率流数据的 deviceTimeArray 与 socArray)
+  const rawPowerFlow = sitePowerFlowDataStore.powerFlowGet(sn, date)
+  const socData = buildSocData(rawPowerFlow)
+
+  // 功率与 SOC 同图渲染(上图功率堆叠面积,下图 SOC 折线,共享 X 轴),并保留图例状态
+  powerFlowChartRef.value?.redraw(powerData, socData, legend)
+}
+
+// 由原始功率流数据构造 SOC 折线数据
+const buildSocData = (
+  rawPowerFlow: ReturnType<typeof sitePowerFlowDataStore.powerFlowGet>,
+): SocChartData[] | undefined => {
+  if (!rawPowerFlow) return undefined
+  const { deviceTimeArray, socArray } = rawPowerFlow
+  if (!deviceTimeArray || !socArray || socArray.length === 0) return undefined
+
+  return deviceTimeArray.map((element, index) => {
+    const time = element.split(' ')[1].slice(0, 5) // 截取时间部分"00:00"
+    const soc = socArray[index] ?? undefined
+    return { Time: time, Soc: soc }
+  })
 }
 
 // 加载站点工作模式