Ver Fonte

调整连油监控看板和瑞恒日报汇总看板调整

Zimo há 1 dia atrás
pai
commit
35b331da00

+ 265 - 169
src/views/oli-connection/monitoring-board/chart.vue

@@ -3,6 +3,7 @@ import { IotDeviceApi } from '@/api/pms/device'
 import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
 import { useMqtt } from '@/utils/useMqtt'
 import { Dimensions, formatIotValue } from '@/utils/useSocketBus'
+import { useDebounceFn } from '@vueuse/core'
 import dayjs from 'dayjs'
 import * as echarts from 'echarts'
 import { neonColors } from '@/utils/td-color'
@@ -83,7 +84,11 @@ const handleMessageUpdate = (_topic: string, data: any) => {
         value
       })
 
-      updateSingleSeries(modelName)
+      if (isFollowingFullRange.value) {
+        resetVisibleTimeRange()
+      }
+
+      updateSeriesByNames([modelName])
     }
   }
 }
@@ -141,100 +146,269 @@ interface ChartData {
 
 const chartData = ref<ChartData>({})
 
-let intervalArr = ref<number[]>([])
-let maxInterval = ref(0)
-let minInterval = ref(0)
-
 const chartRef = ref<HTMLDivElement | null>(null)
 let chart: echarts.ECharts | null = null
 
-function genderIntervalArr(init: boolean = false) {
-  // 1. 使用正负无穷大初始化,避免先把所有数存入数组
-  let maxVal = -Infinity
-  let minVal = Infinity
-  let hasData = false
-
-  // 2. 直接遍历数据查找最值 (不使用 spread ...)
-  for (const [key, value] of Object.entries(selectedDimension.value)) {
-    if (value) {
-      const dataset = chartData.value[key]
-      if (dataset && dataset.length > 0) {
-        hasData = true
-        // 使用循环代替 ...spread
-        for (const item of dataset) {
-          const val = item.value
-          if (val > maxVal) maxVal = val
-          if (val < minVal) minVal = val
-        }
-      }
+const visibleTimeRange = ref<{
+  startTs: number | null
+  endTs: number | null
+}>({
+  startTs: null,
+  endTs: null
+})
+
+const isFollowingFullRange = ref(true)
+
+const TREND_AXIS_MIN = 0
+const TREND_AXIS_MAX = 100
+const TREND_AXIS_CENTER = 50
+const TREND_AXIS_PADDING = 4
+
+interface TrendStats {
+  mode: 'empty' | 'flat' | 'mixed' | 'single'
+  min: number
+  max: number
+  positiveMax: number
+  negativeMin: number
+  fixedValue: number
+}
+
+const resizeChart = useDebounceFn(() => {
+  chart?.resize()
+}, 100)
+
+const getSelectedSeriesNames = () => {
+  return dimensions.value
+    .map((item) => item.name)
+    .filter((name) => selectedDimension.value[name] !== false)
+}
+
+const getGlobalTimeExtent = () => {
+  let minTs = Infinity
+  let maxTs = -Infinity
+
+  Object.values(chartData.value).forEach((dataset) => {
+    dataset.forEach(({ ts }) => {
+      if (!Number.isFinite(ts)) return
+      if (ts < minTs) minTs = ts
+      if (ts > maxTs) maxTs = ts
+    })
+  })
+
+  return {
+    minTs: Number.isFinite(minTs) ? minTs : null,
+    maxTs: Number.isFinite(maxTs) ? maxTs : null
+  }
+}
+
+function resetVisibleTimeRange() {
+  const { minTs, maxTs } = getGlobalTimeExtent()
+
+  visibleTimeRange.value = {
+    startTs: minTs,
+    endTs: maxTs
+  }
+
+  isFollowingFullRange.value = true
+}
+
+function syncVisibleTimeRangeFromChart() {
+  const { minTs, maxTs } = getGlobalTimeExtent()
+
+  if (!chart || minTs === null || maxTs === null) {
+    visibleTimeRange.value = {
+      startTs: minTs,
+      endTs: maxTs
     }
+    isFollowingFullRange.value = true
+    return
   }
 
-  // 3. 处理无数据的默认情况
-  if (!hasData) {
-    maxVal = 10000
-    minVal = 0
+  const dataZoomOptions = chart.getOption().dataZoom
+  const primaryDataZoom = Array.isArray(dataZoomOptions) ? (dataZoomOptions[0] as any) : undefined
+
+  if (!primaryDataZoom) {
+    resetVisibleTimeRange()
+    return
+  }
+
+  const startValue = Number(primaryDataZoom.startValue)
+  const endValue = Number(primaryDataZoom.endValue)
+
+  let nextStartTs = minTs
+  let nextEndTs = maxTs
+
+  if (Number.isFinite(startValue) && Number.isFinite(endValue)) {
+    nextStartTs = Math.min(startValue, endValue)
+    nextEndTs = Math.max(startValue, endValue)
   } else {
-    // 保持你原有的逻辑:如果最小值大于0,则归零
-    minVal = minVal > 0 ? 0 : minVal
+    const startPercent = Number(primaryDataZoom.start ?? 0)
+    const endPercent = Number(primaryDataZoom.end ?? 100)
+    const range = maxTs - minTs
+
+    nextStartTs = minTs + (range * Math.min(startPercent, endPercent)) / 100
+    nextEndTs = minTs + (range * Math.max(startPercent, endPercent)) / 100
   }
 
-  // 4. 计算位数逻辑 (保持不变)
-  const maxDigits = (Math.floor(maxVal) + '').length
-  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+  visibleTimeRange.value = {
+    startTs: nextStartTs,
+    endTs: nextEndTs
+  }
 
-  const interval = Math.max(maxDigits, minDigits)
+  const tolerance = Math.max((maxTs - minTs) * 0.001, 1)
+  isFollowingFullRange.value =
+    Math.abs(nextStartTs - minTs) <= tolerance && Math.abs(nextEndTs - maxTs) <= tolerance
+}
 
-  maxInterval.value = interval
-  minInterval.value = minDigits
+const getVisibleDataset = (dataset: { ts: number; value: number }[]) => {
+  const { startTs, endTs } = visibleTimeRange.value
 
-  intervalArr.value = [0]
-  for (let i = 1; i <= interval; i++) {
-    intervalArr.value.push(Math.pow(10, i))
+  if (startTs === null || endTs === null) {
+    return dataset
   }
 
-  if (!init) {
-    chart?.setOption({
-      yAxis: {
-        min: -minInterval.value,
-        max: maxInterval.value
-      }
-    })
+  return dataset.filter(({ ts }) => ts >= startTs && ts <= endTs)
+}
+
+const getTrendStats = (dataset: { ts: number; value: number }[]): TrendStats => {
+  const values = getVisibleDataset(dataset)
+    .map(({ value }) => Number(value))
+    .filter((value) => Number.isFinite(value))
+
+  if (!values.length) {
+    return {
+      mode: 'empty',
+      min: 0,
+      max: 0,
+      positiveMax: 0,
+      negativeMin: 0,
+      fixedValue: TREND_AXIS_MIN
+    }
+  }
+
+  const min = Math.min(...values)
+  const max = Math.max(...values)
+
+  if (min === max) {
+    return {
+      mode: 'flat',
+      min,
+      max,
+      positiveMax: max > 0 ? max : 0,
+      negativeMin: min < 0 ? min : 0,
+      fixedValue: min === 0 ? TREND_AXIS_MIN : TREND_AXIS_CENTER
+    }
+  }
+
+  if (min < 0 && max > 0) {
+    return {
+      mode: 'mixed',
+      min,
+      max,
+      positiveMax: max,
+      negativeMin: min,
+      fixedValue: TREND_AXIS_CENTER
+    }
+  }
+
+  return {
+    mode: 'single',
+    min,
+    max,
+    positiveMax: max > 0 ? max : 0,
+    negativeMin: min < 0 ? min : 0,
+    fixedValue: TREND_AXIS_CENTER
   }
 }
 
+const mapTrendValue = (value: number, stats: TrendStats) => {
+  const safeValue = Number(value)
+
+  if (!Number.isFinite(safeValue)) return TREND_AXIS_MIN
+
+  if (stats.mode === 'empty') return TREND_AXIS_MIN
+  if (stats.mode === 'flat') return stats.fixedValue
+
+  if (stats.mode === 'mixed') {
+    if (safeValue === 0) return TREND_AXIS_CENTER
+
+    if (safeValue > 0) {
+      const positiveHeight = TREND_AXIS_MAX - TREND_AXIS_CENTER - TREND_AXIS_PADDING
+      return Number(
+        (TREND_AXIS_CENTER + (safeValue / (stats.positiveMax || 1)) * positiveHeight).toFixed(2)
+      )
+    }
+
+    const negativeHeight = TREND_AXIS_CENTER - TREND_AXIS_MIN - TREND_AXIS_PADDING
+    return Number(
+      (
+        TREND_AXIS_CENTER -
+        (Math.abs(safeValue) / Math.abs(stats.negativeMin || -1)) * negativeHeight
+      ).toFixed(2)
+    )
+  }
+
+  const usableHeight = TREND_AXIS_MAX - TREND_AXIS_MIN - TREND_AXIS_PADDING * 2
+  return Number(
+    (
+      TREND_AXIS_PADDING +
+      ((safeValue - stats.min) / (stats.max - stats.min)) * usableHeight
+    ).toFixed(2)
+  )
+}
+
+function formatTrendAxisLabel(value: number) {
+  if (value === TREND_AXIS_MIN) return '低'
+  if (value === TREND_AXIS_CENTER) return '中'
+  if (value === TREND_AXIS_MAX) return '高'
+  return ''
+}
+
+function buildSeriesData(name: string) {
+  const dataset = chartData.value[name] || []
+  const stats = getTrendStats(dataset)
+
+  return dataset.map(({ ts, value }) => [ts, mapTrendValue(value, stats), value])
+}
+
+function updateSeriesByNames(names: string[]) {
+  if (!chart) render()
+  if (!chart || !names.length) return
+
+  chart.setOption({
+    series: names.map((name) => ({
+      name,
+      data: buildSeriesData(name)
+    }))
+  })
+}
+
+const refreshVisibleSeries = useDebounceFn(() => {
+  syncVisibleTimeRangeFromChart()
+  updateSeriesByNames(getSelectedSeriesNames())
+}, 50)
+
 function chartInit() {
   if (!chart) return
 
+  chart.off('legendselectchanged')
   chart.on('legendselectchanged', (params: any) => {
-    // 1. 同步选中状态
     selectedDimension.value = params.selected
     const clickedModelName = params.name
     const isSelected = params.selected[clickedModelName]
 
-    const oldMax = maxInterval.value
-    const oldMin = minInterval.value
-
-    genderIntervalArr()
-
-    const isScaleChanged = oldMax !== maxInterval.value || oldMin !== minInterval.value
-
-    if (isScaleChanged) {
-      Object.keys(selectedDimension.value).forEach((name) => {
-        if (selectedDimension.value[name]) {
-          updateSingleSeries(name)
-        }
-      })
-    } else {
-      if (isSelected) {
-        updateSingleSeries(clickedModelName)
-      }
+    if (isSelected) {
+      updateSeriesByNames([clickedModelName])
     }
   })
 
-  window.addEventListener('resize', () => {
-    if (chart) chart.resize()
+  chart.off('datazoom')
+  chart.on('datazoom', () => {
+    refreshVisibleSeries()
   })
+
+  window.removeEventListener('resize', resizeChart)
+  window.addEventListener('resize', resizeChart)
 }
 
 function render() {
@@ -244,8 +418,6 @@ function render() {
 
   chartInit()
 
-  genderIntervalArr(true)
-
   chart.setOption({
     color: neonColors,
     animation: true,
@@ -275,8 +447,7 @@ function render() {
         color: '#e2e8f0'
       },
       axisPointer: {
-        type: 'cross',
-        label: { backgroundColor: '#22d3ee', color: '#000' },
+        type: 'line',
         lineStyle: { color: 'rgba(255,255,255,0.3)', type: 'dashed' }
       },
       formatter: (params: any) => {
@@ -320,10 +491,11 @@ function render() {
       }
     },
     dataZoom: [
-      { type: 'inside', xAxisIndex: 0 },
+      { type: 'inside', xAxisIndex: 0, filterMode: 'none', throttle: 50 },
       {
         type: 'slider',
         xAxisIndex: 0,
+        filterMode: 'none',
         height: 20,
         bottom: 10,
         borderColor: 'transparent',
@@ -340,22 +512,23 @@ function render() {
           color: '#94a3b8',
           fontSize: 10,
           lineHeight: 12
-        }
+        },
+        throttle: 50
       }
     ],
     yAxis: {
       type: 'value',
-      min: -minInterval.value,
-      max: maxInterval.value,
-      interval: 1,
+      min: TREND_AXIS_MIN,
+      max: TREND_AXIS_MAX,
+      splitNumber: 4,
+      name: '相对趋势',
+      nameGap: 14,
+      nameTextStyle: {
+        color: '#94a3b8'
+      },
       axisLabel: {
         color: '#94a3b8',
-        formatter: (v) => {
-          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
-          if (Math.abs(num) >= 10000) return (num / 10000).toFixed(0) + 'w'
-          if (Math.abs(num) >= 1000) return (num / 1000).toFixed(0) + 'k'
-          return num.toLocaleString()
-        }
+        formatter: formatTrendAxisLabel
       },
       show: true,
       splitLine: {
@@ -366,49 +539,7 @@ function render() {
         }
       },
       axisPointer: {
-        show: true,
-        snap: false, // 必须设为 false,才能平滑显示小数部分的真实值
-        label: {
-          show: true,
-          backgroundColor: '#22d3ee', // 青色背景
-          color: '#000', // 黑色文字
-          fontWeight: 'bold',
-          precision: 2, // 保证精度
-
-          // --- 具体的实现逻辑 ---
-          formatter: (params: any) => {
-            const val = params.value // 这里拿到的是索引值,比如 4.21
-            if (val === 0) return '0.00'
-
-            // A. 处理正负号
-            const sign = val >= 0 ? 1 : -1
-            const absVal = Math.abs(val)
-
-            // B. 分离 整数部分(区间下标) 和 小数部分(区间内百分比)
-            const idx = Math.floor(absVal)
-
-            const percent = absVal - idx
-
-            // C. 安全检查:如果 intervalArr 还没生成,直接返回指数值
-            if (!intervalArr.value || intervalArr.value.length === 0) {
-              return (sign * Math.pow(10, absVal)).toFixed(2)
-            }
-
-            // D. 获取该区间的真实数值范围
-            // 例如 idx=2, 对应 intervalArr[2]=100, intervalArr[3]=1000
-            const min = intervalArr.value[idx]
-            // 如果到了最后一个区间,或者越界,就默认下一级是当前的10倍(防止报错)
-            const max =
-              intervalArr.value[idx + 1] !== undefined ? intervalArr.value[idx + 1] : min * 10
-
-            // E. 反向线性插值公式
-            // 真实值 = 下界 + (上下界之差 * 百分比)
-            const realVal = min + (max - min) * percent
-
-            // F. 加上符号并格式化
-            return (realVal * sign).toFixed(2)
-          }
-        }
+        show: false
       }
     },
     legend: {
@@ -464,48 +595,12 @@ function render() {
         shadowOffsetY: 5
       },
 
+      connectNulls: true,
       data: [] // 占位数组
     }))
   })
 }
 
-function mapData({ value, ts }) {
-  if (value === null || value === undefined || value === 0) return [ts, 0, 0]
-
-  const isPositive = value > 0
-  const absItem = Math.abs(value)
-
-  if (!intervalArr.value.length) return [ts, 0, value]
-
-  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
-  const min_index = intervalArr.value.findIndex((v) => v === min_value)
-
-  let denominator = 1
-  if (min_index < intervalArr.value.length - 1) {
-    denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
-  } else {
-    denominator = intervalArr.value[min_index] || 1
-  }
-
-  const new_value = (absItem - min_value) / denominator + min_index
-
-  return [ts, isPositive ? new_value : -new_value, value]
-}
-
-function updateSingleSeries(name: string) {
-  if (!chart) render()
-  if (!chart) return
-
-  const idx = dimensions.value.findIndex((item) => item.name === name)
-  if (idx === -1) return
-
-  const data = chartData.value[name].map((v) => mapData(v))
-
-  chart.setOption({
-    series: [{ name, data }]
-  })
-}
-
 const lastTsMap = ref<Record<Dimensions['name'], number>>({})
 
 const chartLoading = ref(false)
@@ -514,6 +609,8 @@ async function initLoadChartData(real_time: boolean = true) {
   if (!dimensions.value.length) return
 
   chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+  isFollowingFullRange.value = true
+  resetVisibleTimeRange()
 
   chartLoading.value = true
 
@@ -538,11 +635,13 @@ async function initLoadChartData(real_time: boolean = true) {
 
       chartData.value[name] = sorted
 
-      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+      if (isFollowingFullRange.value) {
+        resetVisibleTimeRange()
+      }
 
-      genderIntervalArr()
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
 
-      updateSingleSeries(name)
+      updateSeriesByNames([name])
 
       chartLoading.value = false
     } finally {
@@ -590,9 +689,7 @@ watch(
 onUnmounted(() => {
   destroy()
 
-  window.removeEventListener('resize', () => {
-    if (chart) chart.resize()
-  })
+  window.removeEventListener('resize', resizeChart)
 })
 
 const router = useRouter()
@@ -608,8 +705,7 @@ function handleDetailClick() {
       name: props.deviceName,
       code: props.deviceCode,
       dept: props.deptName,
-      vehicle: props.vehicleName,
-      mqttUrl: props.mqttUrl
+      vehicle: props.vehicleName
     }
   })
 }

+ 716 - 0
src/views/oli-connection/monitoring-board/chart2.vue

@@ -0,0 +1,716 @@
+<script lang="ts" setup>
+import { IotDeviceApi } from '@/api/pms/device'
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
+import { useMqtt } from '@/utils/useMqtt'
+import { Dimensions, formatIotValue } from '@/utils/useSocketBus'
+import dayjs from 'dayjs'
+import * as echarts from 'echarts'
+import { neonColors } from '@/utils/td-color'
+
+const props = defineProps({
+  id: {
+    type: Number,
+    required: true
+  },
+  deviceCode: {
+    type: String,
+    required: true
+  },
+  deviceName: {
+    type: String,
+    required: true
+  },
+  mqttUrl: {
+    type: String,
+    required: true
+  },
+  ifInline: {
+    type: String,
+    required: true
+  },
+  lastInlineTime: {
+    type: String,
+    required: true
+  },
+  deptName: {
+    type: String,
+    required: true
+  },
+  vehicleName: {
+    type: String,
+    required: true
+  },
+  carOnline: {
+    type: String,
+    required: true
+  },
+  // isRealTime: {
+  //   type: Boolean,
+  //   default: true
+  // },
+  date: {
+    type: Array as PropType<Array<string>>,
+    required: true
+  },
+  token: {
+    type: String,
+    required: true
+  }
+})
+
+const dimensions = ref<Omit<Dimensions, 'color' | 'bgHover' | 'bgActive'>[]>([])
+const selectedDimension = ref<Record<string, boolean>>({})
+
+const { connect, destroy, isConnected, subscribe } = useMqtt()
+
+const handleMessageUpdate = (_topic: string, data: any) => {
+  const valueMap = new Map<string, number>()
+
+  for (const item of data) {
+    const { id: identity, value: logValue, remark } = item
+
+    const value = logValue ? Number(logValue) : 0
+
+    if (identity) {
+      valueMap.set(identity, value)
+    }
+
+    const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
+
+    if (modelName && chartData.value[modelName]) {
+      chartData.value[modelName].push({
+        ts: dayjs.unix(remark).valueOf(),
+        value
+      })
+
+      updateSingleSeries(modelName)
+    }
+  }
+}
+
+watch(isConnected, (newVal) => {
+  if (newVal) {
+    // subscribe(`/636/${props.deviceCode}/property/post`)
+
+    // switch (props.deviceCode) {
+    //   case 'YF1539':
+    //     subscribe(`/656/${props.deviceCode}/property/post`)
+    //   case 'YF325':
+    //   case 'YF288':
+    //   case 'YF671':
+    //   case 'YF459':
+    //     subscribe(`/635/${props.deviceCode}/property/post`)
+    //   case 'YF649':
+    //     subscribe(`/636/${props.deviceCode}/property/post`)
+    //   default:
+    //     subscribe(`/636/${props.deviceCode}/property/post`)
+    // }
+
+    subscribe(props.mqttUrl)
+    // subscribe('/636/YF649/property/post')
+  }
+})
+
+async function loadDimensions() {
+  if (!props.id) return
+  try {
+    dimensions.value = (((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as any[]) ?? [])
+      .sort((a, b) => b.modelOrder - a.modelOrder)
+      .map((item) => {
+        const { value, suffix, isText } = formatIotValue(item.value)
+        return {
+          identifier: item.identifier,
+          name: item.modelName,
+          value: value,
+          suffix: suffix,
+          isText: isText,
+          response: false
+        }
+      })
+      .filter((item) => item.isText === false)
+
+    selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
+  } catch (error) {
+    console.error(error)
+  }
+}
+
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
+}
+
+const chartData = ref<ChartData>({})
+
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+function genderIntervalArr(init: boolean = false) {
+  // 1. 使用正负无穷大初始化,避免先把所有数存入数组
+  let maxVal = -Infinity
+  let minVal = Infinity
+  let hasData = false
+
+  // 2. 直接遍历数据查找最值 (不使用 spread ...)
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
+    if (value) {
+      const dataset = chartData.value[key]
+      if (dataset && dataset.length > 0) {
+        hasData = true
+        // 使用循环代替 ...spread
+        for (const item of dataset) {
+          const val = item.value
+          if (val > maxVal) maxVal = val
+          if (val < minVal) minVal = val
+        }
+      }
+    }
+  }
+
+  // 3. 处理无数据的默认情况
+  if (!hasData) {
+    maxVal = 10000
+    minVal = 0
+  } else {
+    // 保持你原有的逻辑:如果最小值大于0,则归零
+    minVal = minVal > 0 ? 0 : minVal
+  }
+
+  // 4. 计算位数逻辑 (保持不变)
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+  const interval = Math.max(maxDigits, minDigits)
+
+  maxInterval.value = interval
+  minInterval.value = minDigits
+
+  intervalArr.value = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.value.push(Math.pow(10, i))
+  }
+
+  if (!init) {
+    chart?.setOption({
+      yAxis: {
+        min: -minInterval.value,
+        max: maxInterval.value
+      }
+    })
+  }
+}
+
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    // 1. 同步选中状态
+    selectedDimension.value = params.selected
+    const clickedModelName = params.name
+    const isSelected = params.selected[clickedModelName]
+
+    const oldMax = maxInterval.value
+    const oldMin = minInterval.value
+
+    genderIntervalArr()
+
+    const isScaleChanged = oldMax !== maxInterval.value || oldMin !== minInterval.value
+
+    if (isScaleChanged) {
+      Object.keys(selectedDimension.value).forEach((name) => {
+        if (selectedDimension.value[name]) {
+          updateSingleSeries(name)
+        }
+      })
+    } else {
+      if (isSelected) {
+        updateSingleSeries(clickedModelName)
+      }
+    }
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  chartInit()
+
+  genderIntervalArr(true)
+
+  chart.setOption({
+    color: neonColors,
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
+    grid: {
+      left: '3%',
+      top: '60px',
+      right: '6%',
+      bottom: '10%',
+      containLabel: true,
+      show: false
+    },
+    tooltip: {
+      trigger: 'axis',
+      confine: true,
+      enterable: true,
+      className: 'echarts-tooltip-scroll',
+      extraCssText:
+        'max-height: 300px; overflow-y: auto; pointer-events: auto; border-radius: 4px;',
+      backgroundColor: 'rgba(11, 17, 33, 0.95)',
+      borderColor: '#22d3ee',
+      borderWidth: 1,
+      textStyle: {
+        color: '#e2e8f0'
+      },
+      axisPointer: {
+        type: 'cross',
+        label: { backgroundColor: '#22d3ee', color: '#000' },
+        lineStyle: { color: 'rgba(255,255,255,0.3)', type: 'dashed' }
+      },
+      formatter: (params: any) => {
+        let d = `<div style="font-weight:bold; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:5px; margin-bottom:5px;">${params[0].axisValueLabel}</div>`
+        const exist: string[] = []
+        params = params.filter((el: any) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+
+        // 优化列表显示,圆点使用原本的颜色
+        let item = params.map(
+          (
+            el: any
+          ) => `<div class="flex items-center justify-between mt-1" style="font-size:12px; min-width: 180px;">
+            <span style="display:flex; align-items:center;">
+                <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${el.color};margin-right:6px;"></span>
+                <span style="color:#cbd5e1">${el.seriesName}</span>
+            </span>
+            <span style="color:#fff; font-weight:bold; margin-left:10px;">${el.value[2]?.toFixed(2)}</span>
+          </div>`
+        )
+
+        return d + item.join('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      boundaryGap: ['0%', '25%'],
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'center',
+        color: '#94a3b8',
+        fontSize: 11
+      },
+      splitLine: {
+        show: false,
+        lineStyle: { color: 'rgba(255,255,255,0.5)', type: 'dashed' }
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      {
+        type: 'slider',
+        xAxisIndex: 0,
+        height: 20,
+        bottom: 10,
+        borderColor: 'transparent',
+        backgroundColor: 'rgba(255,255,255,0.05)',
+        fillerColor: 'rgba(34,211,238,0.2)',
+        handleStyle: {
+          color: '#22d3ee',
+          borderColor: '#22d3ee'
+        },
+        labelFormatter: (value: any) => {
+          return dayjs(value).format('YYYY-MM-DD\nHH:mm:ss')
+        },
+        textStyle: {
+          color: '#94a3b8',
+          fontSize: 10,
+          lineHeight: 12
+        }
+      }
+    ],
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        color: '#94a3b8',
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+          if (Math.abs(num) >= 10000) return (num / 10000).toFixed(0) + 'w'
+          if (Math.abs(num) >= 1000) return (num / 1000).toFixed(0) + 'k'
+          return num.toLocaleString()
+        }
+      },
+      show: true,
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: 'rgba(255,255,255,0.05)',
+          type: 'dashed'
+        }
+      },
+      axisPointer: {
+        show: true,
+        snap: false, // 必须设为 false,才能平滑显示小数部分的真实值
+        label: {
+          show: true,
+          backgroundColor: '#22d3ee', // 青色背景
+          color: '#000', // 黑色文字
+          fontWeight: 'bold',
+          precision: 2, // 保证精度
+
+          // --- 具体的实现逻辑 ---
+          formatter: (params: any) => {
+            const val = params.value // 这里拿到的是索引值,比如 4.21
+            if (val === 0) return '0.00'
+
+            // A. 处理正负号
+            const sign = val >= 0 ? 1 : -1
+            const absVal = Math.abs(val)
+
+            // B. 分离 整数部分(区间下标) 和 小数部分(区间内百分比)
+            const idx = Math.floor(absVal)
+
+            const percent = absVal - idx
+
+            // C. 安全检查:如果 intervalArr 还没生成,直接返回指数值
+            if (!intervalArr.value || intervalArr.value.length === 0) {
+              return (sign * Math.pow(10, absVal)).toFixed(2)
+            }
+
+            // D. 获取该区间的真实数值范围
+            // 例如 idx=2, 对应 intervalArr[2]=100, intervalArr[3]=1000
+            const min = intervalArr.value[idx]
+            // 如果到了最后一个区间,或者越界,就默认下一级是当前的10倍(防止报错)
+            const max =
+              intervalArr.value[idx + 1] !== undefined ? intervalArr.value[idx + 1] : min * 10
+
+            // E. 反向线性插值公式
+            // 真实值 = 下界 + (上下界之差 * 百分比)
+            const realVal = min + (max - min) * percent
+
+            // F. 加上符号并格式化
+            return (realVal * sign).toFixed(2)
+          }
+        }
+      }
+    },
+    legend: {
+      type: 'scroll', // 开启滚动,防止遮挡
+      top: 10,
+
+      left: 'center',
+      width: '90%',
+
+      textStyle: {
+        color: '#e2e8f0', // 亮白色
+        fontSize: 12
+      },
+      pageIconColor: '#22d3ee',
+      pageIconInactiveColor: '#475569',
+      pageTextStyle: { color: '#fff' },
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: true
+    },
+    // legend: {
+    //   data: dimensions.value.map((item) => item.name),
+    //   selected: selectedDimension.value,
+    //   show: true
+    // },
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+      smooth: 0.3,
+      showSymbol: false,
+
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [4, 0],
+        color: '#fff',
+        backgroundColor: 'auto',
+        padding: [2, 6],
+        borderRadius: 4,
+        fontSize: 11,
+        fontWeight: 'bold'
+      },
+
+      emphasis: {
+        focus: 'series',
+        lineStyle: { width: 4 }
+      },
+
+      lineStyle: {
+        width: 3,
+        shadowColor: 'rgba(0, 0, 0, 0.5)',
+        shadowBlur: 10,
+        shadowOffsetY: 5
+      },
+
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (value === null || value === undefined || value === 0) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  if (!intervalArr.value.length) return [ts, 0, value]
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  let denominator = 1
+  if (min_index < intervalArr.value.length - 1) {
+    denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
+  } else {
+    denominator = intervalArr.value[min_index] || 1
+  }
+
+  const new_value = (absItem - min_value) / denominator + min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = dimensions.value.findIndex((item) => item.name === name)
+  if (idx === -1) return
+
+  const data = chartData.value[name].map((v) => mapData(v))
+
+  chart.setOption({
+    series: [{ name, data }]
+  })
+}
+
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
+
+const chartLoading = ref(false)
+
+async function initLoadChartData(real_time: boolean = true) {
+  if (!dimensions.value.length) return
+
+  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  chartLoading.value = true
+
+  dimensions.value = dimensions.value.map((item) => {
+    item.response = true
+    return item
+  })
+
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+    try {
+      const res = await IotStatApi.getDeviceInfoChart(
+        props.deviceCode,
+        identifier,
+        props.date[0],
+        props.date[1]
+      )
+
+      const sorted = res
+        .sort((a, b) => a.ts - b.ts)
+        .map((item) => ({ ts: item.ts, value: item.value }))
+
+      chartData.value[name] = sorted
+
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+
+      genderIntervalArr()
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
+  }
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+watch(
+  () => props.date,
+  async (newDate, oldDate) => {
+    if (!newDate || newDate.length !== 2) return
+
+    if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
+
+    await cancelAllRequests()
+
+    destroy()
+
+    const endTime = dayjs(newDate[1])
+    const now = dayjs()
+    const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
+
+    if (chart) chart.clear()
+
+    if (isRealTime) initfn(false)
+    else initfn(false, false)
+  }
+)
+
+onUnmounted(() => {
+  destroy()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+
+const router = useRouter()
+
+function handleDetailClick() {
+  router.push({
+    name: 'MonitoringDetail',
+    query: {
+      id: props.id,
+      ifInline: props.ifInline,
+      carOnline: props.carOnline,
+      time: props.lastInlineTime,
+      name: props.deviceName,
+      code: props.deviceCode,
+      dept: props.deptName,
+      vehicle: props.vehicleName
+    }
+  })
+}
+</script>
+<template>
+  <div class="h-100 rounded-lg chart-container flex flex-col">
+    <header class="chart-header justify-between">
+      <div class="flex items-center">
+        <div class="title-icon"></div>
+        <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
+      </div>
+      <el-button link type="primary" class="group" @click="handleDetailClick">
+        详情
+        <div
+          class="i-material-symbols:arrow-right-alt-rounded size-4 transition-transform group-hover:translate-x-1"
+        ></div>
+      </el-button>
+    </header>
+    <main
+      class="flex-1 chart-main"
+      ref="chartRef"
+      v-loading="chartLoading"
+      element-loading-background="transparent"
+    ></main>
+  </div>
+</template>
+<style scoped>
+.chart-container {
+  position: relative;
+  overflow: hidden;
+  background-color: rgb(11 17 33 / 90%);
+  border: 2px solid rgb(34 211 238 / 30%);
+  box-shadow:
+    0 0 20px rgb(0 0 0 / 80%),
+    inset 0 0 15px rgb(34 211 238 / 10%);
+  transition:
+    border-color 0.3s ease,
+    transform 0.3s ease;
+}
+
+.chart-container::before {
+  position: absolute;
+  pointer-events: none;
+  background: radial-gradient(
+    400px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
+    rgb(34 211 238 / 5%),
+    transparent 40%
+  );
+  content: '';
+  inset: 0;
+}
+
+.chart-container:hover {
+  border-color: rgb(34 211 238 / 60%);
+  transform: scale(1.005);
+}
+
+.chart-header {
+  display: flex;
+  padding: 12px 16px;
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 1px;
+  color: #e2e8f0;
+  background: rgb(255 255 255 / 3%);
+  border-bottom: 1px solid transparent;
+  border-image: linear-gradient(to right, rgb(34 211 238 / 50%), transparent) 1;
+  align-items: center;
+}
+
+.title-icon {
+  width: 4px;
+  height: 16px;
+  margin-right: 10px;
+  background: #22d3ee;
+  box-shadow: 0 0 8px #22d3ee;
+}
+
+.chart-main {
+  padding-top: 12px;
+  background-image: radial-gradient(circle at 50% 50%, rgb(34 211 238 / 10%) 0%, transparent 80%),
+    linear-gradient(to right, rgb(34 211 238 / 15%) 1px, transparent 1px),
+    linear-gradient(to bottom, rgb(34 211 238 / 15%) 1px, transparent 1px),
+    linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
+  background-size:
+    100% 100%,
+    40px 40px,
+    40px 40px,
+    100% 100%;
+}
+
+/* 针对 ECharts tooltip 的滚动条美化 */
+.echarts-tooltip-scroll::-webkit-scrollbar {
+  width: 6px;
+}
+
+.echarts-tooltip-scroll::-webkit-scrollbar-thumb {
+  background: #22d3ee; /* 青色滑块 */
+  border-radius: 3px;
+}
+
+.echarts-tooltip-scroll::-webkit-scrollbar-track {
+  background: rgb(255 255 255 / 10%); /* 深色轨道 */
+}
+</style>

+ 717 - 0
src/views/oli-connection/monitoring-board/chart4.vue

@@ -0,0 +1,717 @@
+<script lang="ts" setup>
+import { IotDeviceApi } from '@/api/pms/device'
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
+import { useMqtt } from '@/utils/useMqtt'
+import { Dimensions, formatIotValue } from '@/utils/useSocketBus'
+import dayjs from 'dayjs'
+import * as echarts from 'echarts'
+import { neonColors } from '@/utils/td-color'
+
+const props = defineProps({
+  id: {
+    type: Number,
+    required: true
+  },
+  deviceCode: {
+    type: String,
+    required: true
+  },
+  deviceName: {
+    type: String,
+    required: true
+  },
+  mqttUrl: {
+    type: String,
+    required: true
+  },
+  ifInline: {
+    type: String,
+    required: true
+  },
+  lastInlineTime: {
+    type: String,
+    required: true
+  },
+  deptName: {
+    type: String,
+    required: true
+  },
+  vehicleName: {
+    type: String,
+    required: true
+  },
+  carOnline: {
+    type: String,
+    required: true
+  },
+  // isRealTime: {
+  //   type: Boolean,
+  //   default: true
+  // },
+  date: {
+    type: Array as PropType<Array<string>>,
+    required: true
+  },
+  token: {
+    type: String,
+    required: true
+  }
+})
+
+const dimensions = ref<Omit<Dimensions, 'color' | 'bgHover' | 'bgActive'>[]>([])
+const selectedDimension = ref<Record<string, boolean>>({})
+
+const { connect, destroy, isConnected, subscribe } = useMqtt()
+
+const handleMessageUpdate = (_topic: string, data: any) => {
+  const valueMap = new Map<string, number>()
+
+  for (const item of data) {
+    const { id: identity, value: logValue, remark } = item
+
+    const value = logValue ? Number(logValue) : 0
+
+    if (identity) {
+      valueMap.set(identity, value)
+    }
+
+    const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
+
+    if (modelName && chartData.value[modelName]) {
+      chartData.value[modelName].push({
+        ts: dayjs.unix(remark).valueOf(),
+        value
+      })
+
+      updateSingleSeries(modelName)
+    }
+  }
+}
+
+watch(isConnected, (newVal) => {
+  if (newVal) {
+    // subscribe(`/636/${props.deviceCode}/property/post`)
+
+    // switch (props.deviceCode) {
+    //   case 'YF1539':
+    //     subscribe(`/656/${props.deviceCode}/property/post`)
+    //   case 'YF325':
+    //   case 'YF288':
+    //   case 'YF671':
+    //   case 'YF459':
+    //     subscribe(`/635/${props.deviceCode}/property/post`)
+    //   case 'YF649':
+    //     subscribe(`/636/${props.deviceCode}/property/post`)
+    //   default:
+    //     subscribe(`/636/${props.deviceCode}/property/post`)
+    // }
+
+    subscribe(props.mqttUrl)
+    // subscribe('/636/YF649/property/post')
+  }
+})
+
+async function loadDimensions() {
+  if (!props.id) return
+  try {
+    dimensions.value = (((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as any[]) ?? [])
+      .sort((a, b) => b.modelOrder - a.modelOrder)
+      .map((item) => {
+        const { value, suffix, isText } = formatIotValue(item.value)
+        return {
+          identifier: item.identifier,
+          name: item.modelName,
+          value: value,
+          suffix: suffix,
+          isText: isText,
+          response: false
+        }
+      })
+      .filter((item) => item.isText === false)
+
+    selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
+  } catch (error) {
+    console.error(error)
+  }
+}
+
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
+}
+
+const chartData = ref<ChartData>({})
+
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+function genderIntervalArr(init: boolean = false) {
+  // 1. 使用正负无穷大初始化,避免先把所有数存入数组
+  let maxVal = -Infinity
+  let minVal = Infinity
+  let hasData = false
+
+  // 2. 直接遍历数据查找最值 (不使用 spread ...)
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
+    if (value) {
+      const dataset = chartData.value[key]
+      if (dataset && dataset.length > 0) {
+        hasData = true
+        // 使用循环代替 ...spread
+        for (const item of dataset) {
+          const val = item.value
+          if (val > maxVal) maxVal = val
+          if (val < minVal) minVal = val
+        }
+      }
+    }
+  }
+
+  // 3. 处理无数据的默认情况
+  if (!hasData) {
+    maxVal = 10000
+    minVal = 0
+  } else {
+    // 保持你原有的逻辑:如果最小值大于0,则归零
+    minVal = minVal > 0 ? 0 : minVal
+  }
+
+  // 4. 计算位数逻辑 (保持不变)
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+  const interval = Math.max(maxDigits, minDigits)
+
+  maxInterval.value = interval
+  minInterval.value = minDigits
+
+  intervalArr.value = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.value.push(Math.pow(10, i))
+  }
+
+  if (!init) {
+    chart?.setOption({
+      yAxis: {
+        min: -minInterval.value,
+        max: maxInterval.value
+      }
+    })
+  }
+}
+
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    // 1. 同步选中状态
+    selectedDimension.value = params.selected
+    const clickedModelName = params.name
+    const isSelected = params.selected[clickedModelName]
+
+    const oldMax = maxInterval.value
+    const oldMin = minInterval.value
+
+    genderIntervalArr()
+
+    const isScaleChanged = oldMax !== maxInterval.value || oldMin !== minInterval.value
+
+    if (isScaleChanged) {
+      Object.keys(selectedDimension.value).forEach((name) => {
+        if (selectedDimension.value[name]) {
+          updateSingleSeries(name)
+        }
+      })
+    } else {
+      if (isSelected) {
+        updateSingleSeries(clickedModelName)
+      }
+    }
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  chartInit()
+
+  genderIntervalArr(true)
+
+  chart.setOption({
+    color: neonColors,
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
+    grid: {
+      left: '3%',
+      top: '60px',
+      right: '6%',
+      bottom: '10%',
+      containLabel: true,
+      show: false
+    },
+    tooltip: {
+      trigger: 'axis',
+      confine: true,
+      enterable: true,
+      className: 'echarts-tooltip-scroll',
+      extraCssText:
+        'max-height: 300px; overflow-y: auto; pointer-events: auto; border-radius: 4px;',
+      backgroundColor: 'rgba(11, 17, 33, 0.95)',
+      borderColor: '#22d3ee',
+      borderWidth: 1,
+      textStyle: {
+        color: '#e2e8f0'
+      },
+      axisPointer: {
+        type: 'cross',
+        label: { backgroundColor: '#22d3ee', color: '#000' },
+        lineStyle: { color: 'rgba(255,255,255,0.3)', type: 'dashed' }
+      },
+      formatter: (params: any) => {
+        let d = `<div style="font-weight:bold; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:5px; margin-bottom:5px;">${params[0].axisValueLabel}</div>`
+        const exist: string[] = []
+        params = params.filter((el: any) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+
+        // 优化列表显示,圆点使用原本的颜色
+        let item = params.map(
+          (
+            el: any
+          ) => `<div class="flex items-center justify-between mt-1" style="font-size:12px; min-width: 180px;">
+            <span style="display:flex; align-items:center;">
+                <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${el.color};margin-right:6px;"></span>
+                <span style="color:#cbd5e1">${el.seriesName}</span>
+            </span>
+            <span style="color:#fff; font-weight:bold; margin-left:10px;">${el.value[2]?.toFixed(2)}</span>
+          </div>`
+        )
+
+        return d + item.join('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      boundaryGap: ['0%', '25%'],
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'center',
+        color: '#94a3b8',
+        fontSize: 11
+      },
+      splitLine: {
+        show: false,
+        lineStyle: { color: 'rgba(255,255,255,0.5)', type: 'dashed' }
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      {
+        type: 'slider',
+        xAxisIndex: 0,
+        height: 20,
+        bottom: 10,
+        borderColor: 'transparent',
+        backgroundColor: 'rgba(255,255,255,0.05)',
+        fillerColor: 'rgba(34,211,238,0.2)',
+        handleStyle: {
+          color: '#22d3ee',
+          borderColor: '#22d3ee'
+        },
+        labelFormatter: (value: any) => {
+          return dayjs(value).format('YYYY-MM-DD\nHH:mm:ss')
+        },
+        textStyle: {
+          color: '#94a3b8',
+          fontSize: 10,
+          lineHeight: 12
+        }
+      }
+    ],
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        color: '#94a3b8',
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+          if (Math.abs(num) >= 10000) return (num / 10000).toFixed(0) + 'w'
+          if (Math.abs(num) >= 1000) return (num / 1000).toFixed(0) + 'k'
+          return num.toLocaleString()
+        }
+      },
+      show: true,
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: 'rgba(255,255,255,0.05)',
+          type: 'dashed'
+        }
+      },
+      axisPointer: {
+        show: true,
+        snap: false, // 必须设为 false,才能平滑显示小数部分的真实值
+        label: {
+          show: true,
+          backgroundColor: '#22d3ee', // 青色背景
+          color: '#000', // 黑色文字
+          fontWeight: 'bold',
+          precision: 2, // 保证精度
+
+          // --- 具体的实现逻辑 ---
+          formatter: (params: any) => {
+            const val = params.value // 这里拿到的是索引值,比如 4.21
+            if (val === 0) return '0.00'
+
+            // A. 处理正负号
+            const sign = val >= 0 ? 1 : -1
+            const absVal = Math.abs(val)
+
+            // B. 分离 整数部分(区间下标) 和 小数部分(区间内百分比)
+            const idx = Math.floor(absVal)
+
+            const percent = absVal - idx
+
+            // C. 安全检查:如果 intervalArr 还没生成,直接返回指数值
+            if (!intervalArr.value || intervalArr.value.length === 0) {
+              return (sign * Math.pow(10, absVal)).toFixed(2)
+            }
+
+            // D. 获取该区间的真实数值范围
+            // 例如 idx=2, 对应 intervalArr[2]=100, intervalArr[3]=1000
+            const min = intervalArr.value[idx]
+            // 如果到了最后一个区间,或者越界,就默认下一级是当前的10倍(防止报错)
+            const max =
+              intervalArr.value[idx + 1] !== undefined ? intervalArr.value[idx + 1] : min * 10
+
+            // E. 反向线性插值公式
+            // 真实值 = 下界 + (上下界之差 * 百分比)
+            const realVal = min + (max - min) * percent
+
+            // F. 加上符号并格式化
+            return (realVal * sign).toFixed(2)
+          }
+        }
+      }
+    },
+    legend: {
+      type: 'scroll', // 开启滚动,防止遮挡
+      top: 10,
+
+      left: 'center',
+      width: '90%',
+
+      textStyle: {
+        color: '#e2e8f0', // 亮白色
+        fontSize: 12
+      },
+      pageIconColor: '#22d3ee',
+      pageIconInactiveColor: '#475569',
+      pageTextStyle: { color: '#fff' },
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: true
+    },
+    // legend: {
+    //   data: dimensions.value.map((item) => item.name),
+    //   selected: selectedDimension.value,
+    //   show: true
+    // },
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+      smooth: 0.3,
+      showSymbol: false,
+
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [4, 0],
+        color: '#fff',
+        backgroundColor: 'auto',
+        padding: [2, 6],
+        borderRadius: 4,
+        fontSize: 11,
+        fontWeight: 'bold'
+      },
+
+      emphasis: {
+        focus: 'series',
+        lineStyle: { width: 4 }
+      },
+
+      lineStyle: {
+        width: 3,
+        shadowColor: 'rgba(0, 0, 0, 0.5)',
+        shadowBlur: 10,
+        shadowOffsetY: 5
+      },
+
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (value === null || value === undefined || value === 0) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  if (!intervalArr.value.length) return [ts, 0, value]
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  let denominator = 1
+  if (min_index < intervalArr.value.length - 1) {
+    denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
+  } else {
+    denominator = intervalArr.value[min_index] || 1
+  }
+
+  const new_value = (absItem - min_value) / denominator + min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = dimensions.value.findIndex((item) => item.name === name)
+  if (idx === -1) return
+
+  const data = chartData.value[name].map((v) => mapData(v))
+
+  chart.setOption({
+    series: [{ name, data }]
+  })
+}
+
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
+
+const chartLoading = ref(false)
+
+async function initLoadChartData(real_time: boolean = true) {
+  if (!dimensions.value.length) return
+
+  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  chartLoading.value = true
+
+  dimensions.value = dimensions.value.map((item) => {
+    item.response = true
+    return item
+  })
+
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+    try {
+      const res = await IotStatApi.getDeviceInfoChart(
+        props.deviceCode,
+        identifier,
+        props.date[0],
+        props.date[1]
+      )
+
+      const sorted = res
+        .sort((a, b) => a.ts - b.ts)
+        .map((item) => ({ ts: item.ts, value: item.value }))
+
+      chartData.value[name] = sorted
+
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+
+      genderIntervalArr()
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
+  }
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+watch(
+  () => props.date,
+  async (newDate, oldDate) => {
+    if (!newDate || newDate.length !== 2) return
+
+    if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
+
+    await cancelAllRequests()
+
+    destroy()
+
+    const endTime = dayjs(newDate[1])
+    const now = dayjs()
+    const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
+
+    if (chart) chart.clear()
+
+    if (isRealTime) initfn(false)
+    else initfn(false, false)
+  }
+)
+
+onUnmounted(() => {
+  destroy()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+
+const router = useRouter()
+
+function handleDetailClick() {
+  router.push({
+    name: 'MonitoringDetail',
+    query: {
+      id: props.id,
+      ifInline: props.ifInline,
+      carOnline: props.carOnline,
+      time: props.lastInlineTime,
+      name: props.deviceName,
+      code: props.deviceCode,
+      dept: props.deptName,
+      vehicle: props.vehicleName,
+      mqttUrl: props.mqttUrl
+    }
+  })
+}
+</script>
+<template>
+  <div class="h-100 rounded-lg chart-container flex flex-col">
+    <header class="chart-header justify-between">
+      <div class="flex items-center">
+        <div class="title-icon"></div>
+        <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
+      </div>
+      <el-button link type="primary" class="group" @click="handleDetailClick">
+        详情
+        <div
+          class="i-material-symbols:arrow-right-alt-rounded size-4 transition-transform group-hover:translate-x-1"
+        ></div>
+      </el-button>
+    </header>
+    <main
+      class="flex-1 chart-main"
+      ref="chartRef"
+      v-loading="chartLoading"
+      element-loading-background="transparent"
+    ></main>
+  </div>
+</template>
+<style scoped>
+.chart-container {
+  position: relative;
+  overflow: hidden;
+  background-color: rgb(11 17 33 / 90%);
+  border: 2px solid rgb(34 211 238 / 30%);
+  box-shadow:
+    0 0 20px rgb(0 0 0 / 80%),
+    inset 0 0 15px rgb(34 211 238 / 10%);
+  transition:
+    border-color 0.3s ease,
+    transform 0.3s ease;
+}
+
+.chart-container::before {
+  position: absolute;
+  pointer-events: none;
+  background: radial-gradient(
+    400px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
+    rgb(34 211 238 / 5%),
+    transparent 40%
+  );
+  content: '';
+  inset: 0;
+}
+
+.chart-container:hover {
+  border-color: rgb(34 211 238 / 60%);
+  transform: scale(1.005);
+}
+
+.chart-header {
+  display: flex;
+  padding: 12px 16px;
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 1px;
+  color: #e2e8f0;
+  background: rgb(255 255 255 / 3%);
+  border-bottom: 1px solid transparent;
+  border-image: linear-gradient(to right, rgb(34 211 238 / 50%), transparent) 1;
+  align-items: center;
+}
+
+.title-icon {
+  width: 4px;
+  height: 16px;
+  margin-right: 10px;
+  background: #22d3ee;
+  box-shadow: 0 0 8px #22d3ee;
+}
+
+.chart-main {
+  padding-top: 12px;
+  background-image: radial-gradient(circle at 50% 50%, rgb(34 211 238 / 10%) 0%, transparent 80%),
+    linear-gradient(to right, rgb(34 211 238 / 15%) 1px, transparent 1px),
+    linear-gradient(to bottom, rgb(34 211 238 / 15%) 1px, transparent 1px),
+    linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
+  background-size:
+    100% 100%,
+    40px 40px,
+    40px 40px,
+    100% 100%;
+}
+
+/* 针对 ECharts tooltip 的滚动条美化 */
+.echarts-tooltip-scroll::-webkit-scrollbar {
+  width: 6px;
+}
+
+.echarts-tooltip-scroll::-webkit-scrollbar-thumb {
+  background: #22d3ee; /* 青色滑块 */
+  border-radius: 3px;
+}
+
+.echarts-tooltip-scroll::-webkit-scrollbar-track {
+  background: rgb(255 255 255 / 10%); /* 深色轨道 */
+}
+</style>

+ 268 - 131
src/views/pms/iotrhdailyreport/summary.vue

@@ -1,12 +1,14 @@
 <script setup lang="ts">
 import dayjs from 'dayjs'
+import type { ECharts } from 'echarts/core'
+import { DataZoomComponent } from 'echarts/components'
 import {
   IotRhDailyReportApi,
   type IotRhDailyReportTotalWorkloadVO
 } from '@/api/pms/iotrhdailyreport'
 import { useDebounceFn } from '@vueuse/core'
 import CountTo from '@/components/count-to1.vue'
-import * as echarts from 'echarts'
+import echarts from '@/plugins/echarts'
 import UnfilledReportDialog from './UnfilledReportDialog.vue'
 
 import { Motion, AnimatePresence } from 'motion-v'
@@ -17,6 +19,8 @@ import download from '@/utils/download'
 import { useUserStore } from '@/store/modules/user'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 
+echarts.use([DataZoomComponent])
+
 const deptId = useUserStore().getUser.deptId
 
 interface Query {
@@ -283,25 +287,41 @@ const handleSelectTab = (val: '表格' | '看板') => {
 }
 
 const chartRef = ref<HTMLDivElement | null>(null)
-let chart: echarts.ECharts | null = null
+let chart: ECharts | null = null
 let chartContainerEl: HTMLDivElement | null = null
 
 const xAxisData = ref<string[]>([])
 
-const legend = ref<string[][]>([
-  ['累计油耗 (升)', 'cumulativeFuelConsumption'],
-  // ['累计油耗 (万升)', 'cumulativeFuelConsumption'],
-  ['累计注气量 (万方)', 'cumulativeGasInjection'],
-  // ['累计注气量 (万方)', 'cumulativeGasInjection'],
-  ['累计用电量 (KWh)', 'cumulativePowerConsumption'],
-  // ['累计用电量 (MWh)', 'cumulativePowerConsumption'],
-  ['累计注水量 (方)', 'cumulativeWaterInjection'],
-  // ['累计注水量 (万方)', 'cumulativeWaterInjection'],
-  ['平均时效 (%)', 'transitTime'],
-  ['设备利用率 (%)', 'utilizationRate']
-])
-
-const chartData = ref<Record<string, number[]>>({
+type ChartKey =
+  | 'cumulativeFuelConsumption'
+  | 'cumulativeGasInjection'
+  | 'cumulativePowerConsumption'
+  | 'cumulativeWaterInjection'
+  | 'transitTime'
+  | 'utilizationRate'
+
+interface LegendItem {
+  name: string
+  key: ChartKey
+  unit: string
+  decimals: number
+}
+
+const legendItems: LegendItem[] = [
+  { name: '累计油耗 (升)', key: 'cumulativeFuelConsumption', unit: '升', decimals: 2 },
+  { name: '累计注气量 (万方)', key: 'cumulativeGasInjection', unit: '万方', decimals: 2 },
+  { name: '累计用电量 (KWh)', key: 'cumulativePowerConsumption', unit: 'KWh', decimals: 2 },
+  { name: '累计注水量 (方)', key: 'cumulativeWaterInjection', unit: '方', decimals: 2 },
+  { name: '平均时效 (%)', key: 'transitTime', unit: '%', decimals: 2 },
+  { name: '设备利用率 (%)', key: 'utilizationRate', unit: '%', decimals: 2 }
+]
+
+const legendItemMap = legendItems.reduce<Record<string, LegendItem>>((map, item) => {
+  map[item.name] = item
+  return map
+}, {})
+
+const chartData = ref<Record<ChartKey, number[]>>({
   cumulativeFuelConsumption: [],
   cumulativeGasInjection: [],
   cumulativePowerConsumption: [],
@@ -331,97 +351,249 @@ const getChart = useDebounceFn(async () => {
     }
 
     xAxisData.value = res.map((item) => item.reportDate || '')
+    resetVisibleZoomRange()
   } finally {
     chartLoading.value = false
   }
 }, 500)
 
-const resizer = () => {
+const resizer = useDebounceFn(() => {
   chart?.resize()
-}
+}, 100)
 
-onUnmounted(() => {
-  window.removeEventListener('resize', resizer)
-  chart?.dispose()
-  chart = null
-  chartContainerEl = null
+const selectedLegends = ref<Record<string, boolean>>({})
+const visibleZoomRange = ref({
+  startIndex: 0,
+  endIndex: 0
 })
 
-const selectedLegends = ref<Record<string, boolean>>({})
-const intervalArr = ref<number[]>([])
-const maxInterval = ref(0)
-const minInterval = ref(0)
-
-const calcIntervals = () => {
-  let maxVal = -Infinity
-  let minVal = Infinity
-  let hasData = false
-
-  for (const [name, key] of legend.value) {
-    if (selectedLegends.value[name] !== false) {
-      const dataset = chartData.value[key] || []
-      if (dataset.length > 0) {
-        hasData = true
-        for (const val of dataset) {
-          if (val > maxVal) maxVal = val
-          if (val < minVal) minVal = val
-        }
-      }
+const NORMALIZED_AXIS_MIN = 0
+const NORMALIZED_AXIS_MAX = 100
+const NORMALIZED_AXIS_CENTER = 50
+const NORMALIZED_AXIS_PADDING = 4
+
+const ensureLegendSelection = () => {
+  legendItems.forEach(({ name }) => {
+    if (selectedLegends.value[name] === undefined) {
+      selectedLegends.value[name] = true
     }
+  })
+}
+
+const resetVisibleZoomRange = () => {
+  const lastIndex = Math.max(xAxisData.value.length - 1, 0)
+
+  visibleZoomRange.value = {
+    startIndex: 0,
+    endIndex: lastIndex
   }
+}
 
-  if (!hasData) {
-    maxVal = 10000
-    minVal = 0
-  } else {
-    minVal = minVal > 0 ? 0 : minVal
+const clampZoomIndex = (value: number, lastIndex: number) => {
+  return Math.min(Math.max(Math.round(value), 0), lastIndex)
+}
+
+const syncVisibleZoomRangeFromChart = () => {
+  const lastIndex = Math.max(xAxisData.value.length - 1, 0)
+
+  if (!chart || lastIndex <= 0) {
+    resetVisibleZoomRange()
+    return
+  }
+
+  const dataZoomOptions = chart.getOption().dataZoom
+  const primaryDataZoom = Array.isArray(dataZoomOptions) ? dataZoomOptions[0] : undefined
+
+  if (!primaryDataZoom) {
+    resetVisibleZoomRange()
+    return
   }
 
-  const maxDigits = (Math.floor(maxVal) + '').length
-  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
-  const interval = Math.max(maxDigits, minDigits)
+  const startValue = Number(primaryDataZoom.startValue)
+  const endValue = Number(primaryDataZoom.endValue)
+
+  let nextStartIndex = 0
+  let nextEndIndex = lastIndex
 
-  maxInterval.value = interval
-  minInterval.value = minDigits
+  if (Number.isFinite(startValue) && Number.isFinite(endValue)) {
+    nextStartIndex = clampZoomIndex(startValue, lastIndex)
+    nextEndIndex = clampZoomIndex(endValue, lastIndex)
+  } else {
+    const startPercent = Number(primaryDataZoom.start ?? 0)
+    const endPercent = Number(primaryDataZoom.end ?? 100)
+
+    nextStartIndex = clampZoomIndex((startPercent / 100) * lastIndex, lastIndex)
+    nextEndIndex = clampZoomIndex((endPercent / 100) * lastIndex, lastIndex)
+  }
 
-  const arr = [0]
-  for (let i = 1; i <= interval; i++) {
-    arr.push(Math.pow(10, i))
+  visibleZoomRange.value = {
+    startIndex: Math.min(nextStartIndex, nextEndIndex),
+    endIndex: Math.max(nextStartIndex, nextEndIndex)
   }
-  intervalArr.value = arr
 }
 
-const mapDataValue = (value: number) => {
-  if (value === 0) return 0
+const getVisibleDatasetValues = (dataset: number[]) => {
+  if (!dataset.length) return []
 
-  const isPositive = value > 0
-  const absItem = Math.abs(value)
+  const lastIndex = dataset.length - 1
+  const startIndex = clampZoomIndex(visibleZoomRange.value.startIndex, lastIndex)
+  const endIndex = Math.max(startIndex, clampZoomIndex(visibleZoomRange.value.endIndex, lastIndex))
 
-  if (!intervalArr.value.length) return value
+  return dataset.slice(startIndex, endIndex + 1).filter((value) => Number.isFinite(value))
+}
+
+const normalizeSeriesData = (dataset: number[]) => {
+  if (!dataset.length) return []
+
+  const validValues = getVisibleDatasetValues(dataset)
 
-  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
-  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+  if (!validValues.length) {
+    return dataset.map(() => NORMALIZED_AXIS_MIN)
+  }
+
+  const min = Math.min(...validValues)
+  const max = Math.max(...validValues)
 
-  const denominator =
-    min_index < intervalArr.value.length - 1
-      ? intervalArr.value[min_index + 1] - intervalArr.value[min_index]
-      : intervalArr.value[min_index] || 1
+  if (min === max) {
+    return dataset.map(() => (min === 0 ? NORMALIZED_AXIS_MIN : NORMALIZED_AXIS_CENTER))
+  }
 
-  const new_value = (absItem - min_value) / denominator + min_index
+  const usableHeight = NORMALIZED_AXIS_MAX - NORMALIZED_AXIS_MIN - NORMALIZED_AXIS_PADDING * 2
 
-  return isPositive ? new_value : -new_value
+  return dataset.map((value) =>
+    Number((NORMALIZED_AXIS_PADDING + ((value - min) / (max - min)) * usableHeight).toFixed(2))
+  )
+}
+
+const formatTrendAxisLabel = (value: number) => {
+  if (value === NORMALIZED_AXIS_MIN) return '低'
+  if (value === NORMALIZED_AXIS_CENTER) return '中'
+  if (value === NORMALIZED_AXIS_MAX) return '高'
+  return ''
+}
+
+const formatSeriesValue = (name: string, value: number) => {
+  const item = legendItemMap[name]
+  const safeValue = Number.isFinite(value) ? value : 0
+
+  if (!item) return safeValue.toFixed(2)
+
+  return `${safeValue.toFixed(item.decimals)} ${item.unit}`
 }
 
 const getSeries = () => {
-  return legend.value.map(([name, key]) => ({
-    name,
+  const enableSampling = xAxisData.value.length > 120
+
+  return legendItems.map((item) => ({
+    name: item.name,
     type: 'line',
-    smooth: true,
+    smooth: false,
     showSymbol: true,
-    data: chartData.value[key].map((value) => mapDataValue(value))
+    symbol: 'circle',
+    symbolSize: 6,
+    connectNulls: true,
+    lineStyle: {
+      width: 2
+    },
+    emphasis: {
+      focus: 'series'
+    },
+    sampling: enableSampling ? 'lttb' : undefined,
+    progressive: 300,
+    progressiveThreshold: 1500,
+    data: normalizeSeriesData(chartData.value[item.key])
   }))
 }
 
+const getChartOption = () => ({
+  animation: xAxisData.value.length <= 120,
+  animationDuration: 280,
+  animationDurationUpdate: 180,
+  grid: {
+    top: 72,
+    right: 24,
+    bottom: 68,
+    left: 48,
+    containLabel: true
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: { type: 'line' },
+    formatter: (params: any) => {
+      const list = Array.isArray(params) ? params : [params]
+
+      if (!list.length) return ''
+
+      const content = list.map((item: any) => {
+        const legendItem = legendItemMap[item.seriesName]
+        const realValue = legendItem ? (chartData.value[legendItem.key][item.dataIndex] ?? 0) : 0
+
+        return `<div class="flex items-center justify-between mt-1 gap-1">
+            <span>${item.marker} ${item.seriesName}</span>
+            <span>${formatSeriesValue(item.seriesName, realValue)}</span>
+          </div>`
+      })
+
+      return `${list[0].axisValueLabel}<br>${content.join('')}`
+    }
+  },
+  legend: {
+    type: 'scroll',
+    top: 16,
+    data: legendItems.map((item) => item.name),
+    selected: selectedLegends.value,
+    show: true
+  },
+  xAxis: {
+    type: 'category',
+    boundaryGap: false,
+    data: xAxisData.value,
+    axisLabel: {
+      hideOverlap: true
+    }
+  },
+  dataZoom: [
+    {
+      type: 'inside',
+      xAxisIndex: 0,
+      filterMode: 'none',
+      throttle: 50,
+      startValue: visibleZoomRange.value.startIndex,
+      endValue: visibleZoomRange.value.endIndex
+    },
+    {
+      type: 'slider',
+      xAxisIndex: 0,
+      filterMode: 'none',
+      height: 18,
+      bottom: 8,
+      brushSelect: false,
+      showDetail: false,
+      moveHandleSize: 0,
+      throttle: 50,
+      startValue: visibleZoomRange.value.startIndex,
+      endValue: visibleZoomRange.value.endIndex
+    }
+  ],
+  yAxis: {
+    type: 'value',
+    min: NORMALIZED_AXIS_MIN,
+    max: NORMALIZED_AXIS_MAX,
+    splitNumber: 4,
+    name: '相对趋势',
+    nameGap: 16,
+    axisLabel: {
+      formatter: formatTrendAxisLabel
+    },
+    splitLine: {
+      lineStyle: {
+        type: 'dashed'
+      }
+    }
+  },
+  series: getSeries()
+})
+
 const initChart = () => {
   if (!chartRef.value) return
 
@@ -430,22 +602,25 @@ const initChart = () => {
   }
 
   chart?.dispose()
-  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+  window.removeEventListener('resize', resizer)
+  chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas', useDirtyRect: true })
   chartContainerEl = chartRef.value
   window.addEventListener('resize', resizer)
 
+  chart.off('legendselectchanged')
   chart.on('legendselectchanged', (params: any) => {
-    selectedLegends.value = params.selected
-
-    calcIntervals()
+    selectedLegends.value = { ...params.selected }
+  })
 
-    chart?.setOption({
-      yAxis: {
-        min: -minInterval.value,
-        max: maxInterval.value
+  chart.off('datazoom')
+  chart.on('datazoom', () => {
+    syncVisibleZoomRangeFromChart()
+    chart?.setOption(
+      {
+        series: getSeries()
       },
-      series: getSeries()
-    })
+      { lazyUpdate: true }
+    )
   })
 }
 
@@ -454,56 +629,18 @@ const render = () => {
 
   initChart()
 
-  legend.value.forEach(([name]) => {
-    selectedLegends.value[name] = true
-  })
-
-  calcIntervals()
+  ensureLegendSelection()
 
-  chart?.setOption(
-    {
-      tooltip: {
-        trigger: 'axis',
-        axisPointer: { type: 'line' },
-        formatter: (params: any) => {
-          let d = `${params[0].axisValueLabel}<br>`
-          let item = params.map((el: any) => {
-            const realValue = chartData.value[legend.value[el.componentIndex][1]][el.dataIndex]
-            return `<div class="flex items-center justify-between mt-1 gap-1">
-            <span>${el.marker} ${el.seriesName}</span>
-            <span>${realValue.toFixed(2)} ${el.seriesName.split(' ')[1] || ''}</span>
-          </div>`
-          })
-          return d + item.join('')
-        }
-      },
-      legend: {
-        data: legend.value.map(([name]) => name),
-        selected: selectedLegends.value,
-        show: true
-      },
-      xAxis: {
-        type: 'category',
-        data: xAxisData.value
-      },
-      yAxis: {
-        type: 'value',
-        min: -minInterval.value,
-        max: maxInterval.value,
-        interval: 1,
-        axisLabel: {
-          formatter: (v: number) => {
-            const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
-            return num.toLocaleString()
-          }
-        }
-      },
-      series: getSeries()
-    },
-    true
-  )
+  chart?.setOption(getChartOption(), { notMerge: true, lazyUpdate: true })
 }
 
+onUnmounted(() => {
+  window.removeEventListener('resize', resizer)
+  chart?.dispose()
+  chart = null
+  chartContainerEl = null
+})
+
 const handleDeptNodeClick = (node: any) => {
   deptName.value = node.name
   handleQuery()