Echarts - 多图表联动范围选择 且 选择器固定顶部实现

208 阅读6分钟

Echarts - 多图表联动范围选择 且 选择器固定顶部实现

效果图

image.png

image.png

功能点

  1. 多个图表仅靠最顶层的范围选择即可一起联动
  2. 范围选择固定在顶部,不随图表一起滚动
  3. 图表动态生成 高度动态 标题自定义动态 位置动态
  4. 异常区间告警(方法注释了,需要可以开启)

实现

这里不过多解释了,直接贴代码,有需要的可以用心阅读下
ps:顶部固定是采用双图表实现的

// 动态生成的方法
// 动态生成图表初始化配置
export function getInitChartOption(data: any[], dateList: string[]) {
  const formatter = (params: any) => {
    let result = params[0].name + '<br>' // 显示时间
    params.forEach((item: any) => {
      const unit = data.find((d) => d.name === item.seriesName).unit || ''
      result += `${item.marker} ${item.seriesName}: ${item.value} ${unit}<br>`
    })
    return result
  }
  const dataZoomObj = {
    xAxisIndex1: [0, data.length - 1],
    xAxisIndex2: data.map((_item, index) => index)
  }
  const grid = data.map((_i, index) => {
    return {
      left: 40,
      right: 40,
      // top: 275 * index + 75 + '',
      top: 275 * index + 60 + '',
      height: '200'
    }
  })
  const xAxis = data.map((_i, index) => {
    return {
      gridIndex: index,
      type: 'category',
      boundaryGap: false,
      axisLine: {
        onZero: true,
        lineStyle: {
          color: '#fff'
        }
      },
      data: dateList,
      axisLabel: {
        color: '#fff'
      }
    }
  })

  const yAxis = data.map((item, index) => {
    return {
      gridIndex: index,
      // name: item.name + '(' + item.unit + ')',
      type: 'value',
      axisLabel: {
        color: '#fff'
      },
      axisLine: {
        lineStyle: {
          color: '#fff'
        }
      },
      splitLine: {
        show: true,
        lineStyle: {
          color: '#107779',
          type: 'dashed'
        }
      }
      // min: item.name === '二氧化碳' ? 25 : undefined
    }
  })

  const series = data.map((item, index) => {
    return {
      name: item.name,
      type: 'line',
      // lineStyle: {
      //   color: '#00DAA0'
      // },
      markArea: {
        show: item.markAreaData?.length,
        itemStyle: {
          color: 'rgba(255,0,0,0.15)'
        },
        data: item.markAreaData
      },
      xAxisIndex: index,
      yAxisIndex: index,
      symbolSize: 8,
      data: item.data
      // itemStyle: {
      //   color: '#00DAA0'
      // }
    }
  })

  const option = {
    title: {
      text: '',
      left: 'center'
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        animation: false
      },
      formatter
    },
    axisPointer: {
      link: [
        {
          xAxisIndex: 'all'
        }
      ]
    },
    dataZoom: [
      {
        show: true,
        realtime: true,
        start: 80,
        end: 100,
        xAxisIndex: dataZoomObj.xAxisIndex1,
        top: '100%',
        // bottom: '0%',
        left: '12%',
        right: '12%',
        textStyle: {
          // 新增/修改文本样式配置
          color: '#FFFFFF', // 白色文字
          fontSize: 12
        }
        // orient: 'vertical',
      },
      {
        type: 'inside',
        realtime: true,
        orient: 'vertical',
        start: 80,
        end: 100,
        xAxisIndex: dataZoomObj.xAxisIndex2,
        zoomOnMouseWheel: 'alt'
      }
    ],
    grid,
    xAxis,
    yAxis,
    series
    // visualMap: {
    //   show: false,
    //   dimension: 0,
    //   pieces: [
    //     {
    //       gt: 2,
    //       lte: 6,
    //       color: 'red'
    //     },
    //     {
    //       gt: 8,
    //       lte: 10,
    //       color: 'yellow'
    //     },
    //   ]
    // }
  }
  return option
}

// 动态生成滑块图表初始化配置
export function getInitChartSliderOption(data: any[], dateList: string[]) {
  const dataZoomObj = {
    xAxisIndex1: [0, data.length - 1],
    xAxisIndex2: data.map((_item, index) => index)
  }

  const option = {
    title: {
      text: '',
      left: 'center'
    },
    tooltip: {
      show: false
    },
    dataZoom: [
      {
        show: true,
        realtime: true,
        start: 80,
        end: 100,
        xAxisIndex: dataZoomObj.xAxisIndex1,
        top: '0%',
        left: '12%',
        right: '12%',
        textStyle: {
          // 新增/修改文本样式配置
          color: '#FFFFFF', // 白色文字
          fontSize: 12
        }
        // orient: 'vertical',
      },
      {
        type: 'inside',
        realtime: true,
        orient: 'vertical',
        start: 80,
        end: 100,
        xAxisIndex: dataZoomObj.xAxisIndex2,
        zoomOnMouseWheel: 'alt'
      }
    ],
    grid: {
      left: '12%',
      right: '12%',
      top: '20%',
      bottom: '20%'
    },
    xAxis: {
      show: false,
      type: 'category',
      data: dateList,
      position: 'bottom'
    },
    yAxis: {
      show: false
    },
    series: [
      {
        type: 'line',
        data: Array(dateList.length).fill(0),
        showSymbol: false,
        lineStyle: { opacity: 0 }
      }
    ]
  }
  return option
}
// 获取异常区间数据
export const getWarningFn = (data: any[], basicData: any[]) => {
  const warnObj: any = {},
    middleObj: any = {}
  data.forEach((item, index) => {
    basicData.forEach((iten) => {
      if (!warnObj[iten.key] && iten.basic) {
        warnObj[iten.key] = []
        middleObj[iten.key] = []
      }
      if (judegNext(data, item, iten, index)) {
        const arr = middleObj[iten.key]
        if (arr.length === 0) {
          arr.push({ xAxis: item.uploadTime }) // 记录开始时间
        }
      } else if (iten.basic) {
        const arr = middleObj[iten.key]
        if (arr.length > 0) {
          arr.push({ xAxis: item.uploadTime }) // 记录结束时间
          warnObj[iten.key].push([...arr])
          middleObj[iten.key] = []
        }
      }
    })
  })
  console.log(warnObj, 'warnObj----------------------->')
  return warnObj
}

function judegNext(data: any, item: any, iten: any, index: number) {
  const len = data.length
  if (index + 1 > len) return false
  const nextItem = data[index + 1]
  console.log(nextItem, 'nextItem----------------------->')
  if (
    iten.basic &&
    item[iten.key] &&
    Number(item[iten.key]) > iten.basic &&
    nextItem?.[iten.key] &&
    nextItem[iten.key] > iten.basic
  )
    return true
  return false
}

文件代码


<template>
  <div class="device-echarts">
    <el-empty
      description="暂无数据"
      v-if="noData || loading"
      v-loading="loading"
      element-loading-background="#094a5b"
    />
    <div class="echart-box" v-else>
      <v-chart
        v-show="!noData && !loading"
        class="echart-slider"
        ref="mapChartSlider"
        :option="rankChart"
        autoresize
      />
      <div class="scroll-box">
        <div class="echart-wrapper">
          <v-chart
            v-show="!noData && !loading"
            class="echart"
            ref="mapChart"
            :option="rankChart"
            autoresize
          />
          <template v-if="titleList.length > 0 && !loading && !noData">
            <div
              class="title-item"
              v-for="item in titleList"
              :key="item.key"
              :style="{ top: item.top }"
            >
              <img :src="titlePre" alt="" />
              <span>{{ item.name }}</span>
            </div>
          </template>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import {
  getData,
  getRandomNum,
  getInitChartOption,
  getInitChartSliderOption,
  testData,
  waterQualityData,
  electricityData,
  rubbishData,
  getKeyValue,
  toiletDeviceData,
  getWarningFn,
  handleOverflow
} from './echart'
import {
  queryWaterDeviceHistoryData,
  queryToiletDeviceHistoryData,
  queryElecDeviceHistoryData,
  queryRubbishDeviceHistoryData
} from '@/api/manageDispatch/device'
import titlePre from '@/assets/images/manageDispatch/device/title-pre.png'
import dayjs from 'dayjs'
import * as echarts from 'echarts'

const props = defineProps({
  data: {
    type: Object
  },
  detailType: {
    type: String
  }
})

const rankChart = ref<any>(null)
const rankChartSlider = ref<any>(null)
const mapChart = ref<any>(null)
const mapChartSlider = ref<any>(null)
// 100 * 1600 / 1080) vh 400 * 4(个数)
const chartHeight = ref('0vh') // 400 * 4(个数)
const loading = ref(false)
const noData = ref(true)
const titleList = ref<any>([])

function getHistoryData() {
  loading.value = true
  // 以前一天为基准 获取一个月的范围时间
  const latsDay = dayjs().subtract(1, 'day').format('YYYY-MM-DD') + ' 23:59:59'
  const latsMonthDay = dayjs().subtract(1, 'month').format('YYYY-MM-DD') + ' 00:00:00'
  // 不同设备类型 获取不同的数据
  let len = 0,
    arr = [],
    fn = queryWaterDeviceHistoryData
  if (props.detailType === 'toilet') {
    len = toiletDeviceData.value.length
    arr = toiletDeviceData.value
    fn = queryToiletDeviceHistoryData
  } else if (props.detailType === 'electricity') {
    len = electricityData.value.length
    arr = electricityData.value
    fn = queryElecDeviceHistoryData
  } else if (props.detailType === 'rubbish') {
    len = rubbishData.value.length
    arr = rubbishData.value
    fn = queryRubbishDeviceHistoryData
  } else {
    len = waterQualityData.value.length
    arr = waterQualityData.value
  }
  const target = getKeyValue(arr)

  titleList.value = arr.map((item: any, index: number) => {
    // const top = index * 375 + 70
    // const he = (100 * top) / 1080 + 'vh'
    const top = index * 275 + 18
    const newHe = top + 'px'
    return {
      name: item.unit ? item.name + '(' + item.unit + ')' : item.name,
      top: newHe,
      key: item.key
    }
  })
  const dateList: string[] = []
  fn({
    deviceId: props.data?.deviceId,
    startTime: latsMonthDay,
    endTime: latsDay
  })
    .then((res) => {
      let arrData = res
      if (!res?.length) {
        noData.value = true
        return
      }
      if (props.detailType === 'toilet') {
        arrData = res?.[0].toiletAlarmRecordData
      }
      if (!arrData?.length) {
        noData.value = true
        return
      }
      // 获取报警区间 面积数据
      // const AreaTarget: any = getWarningFn(res, arr)
      getChartHeight(len)
      noData.value = false
      arrData.forEach((item: any) => {
        const time = dayjs(item.uploadTime || item.time).format('MM-DD HH:mm')
        dateList.push(time)
        for (const key in target) {
          if (item[key]) {
            target[key].push(item[key])
          } else {
            target[key].push(0)
          }
        }
      })
      // console.log(target, 'target---------------->')
      // console.log(dateList, 'dateList---------------->')
      arr.forEach((item: any) => {
        item.data = target[item.key]
        // item.markAreaData = AreaTarget[item.key]?.length > 0 ? AreaTarget[item.key] : []
      })

      initChart(arr, dateList)
    })
    .finally(() => {
      loading.value = false
    })
}
function getChartHeight(num: number) {
  const h = num * 400
  const he = (100 * h) / 1080 + 'vh'
  const newHeight = 270 * num + 100 + 'px'
  chartHeight.value = newHeight
}
getChartHeight(1)

onMounted(() => {
  getHistoryData() // 真实后台请求数据
  // textEchat() // 测试数据
})

function initChart(data: any, dateList: any) {
  const option = getInitChartOption(data, dateList)
  // 使用刚指定的配置项和数据显示图表。
  rankChart.value = option
  const optionSlider = getInitChartSliderOption(data, dateList)
  rankChartSlider.value = optionSlider
  setTimeout(() => {
    nextTick(() => {
      const mainChart = mapChart.value?.chart
      mainChart.setOption({
        dataZoom: [
          {
            start: 80,
            end: 100,
            top: '100%'
          },
          {
            start: 80,
            end: 100
          }
        ]
      })
      const sliderChart = mapChartSlider.value?.chart
      sliderChart.setOption({
        dataZoom: [
          {
            start: 80,
            end: 100,
            top: '0%'
          },
          {
            start: 80,
            end: 100
          }
        ]
      })
      console.log(mainChart, 'mainChart---------------->', sliderChart)
      // 监听 control 图表的 datazoom 事件,并同步到 main 图表
      // echarts.connect([sliderChart, mainChart])
      syncDataZoom(sliderChart, mainChart)
    })
  }, 100)

  // setTimeout(() => {
  //   nextTick(() => {
  //     handleOverflow(mapChart.value.chart, option)
  //   })
  // }, 1000)
  // handleOverflow(mapChart.value)
}
function textEchat() {
  noData.value = false
  loading.value = false
  titleList.value = testData.value.map((item: any, index: number) => {
    const top = index * 275 + 18
    const he = (100 * top) / 1080 + 'vh'
    const newHe = top + 'px'
    return {
      name: item.unit ? item.name + '(' + item.unit + ')' : item.name,
      top: newHe,
      key: item.key
    }
  })
  getChartHeight(testData.value.length)
  const data = getData()
  const dateList = data.map((str) => str.replace('2025/', ''))
  testData.value[0].data = data.map(() => getRandomNum(1, 80) / 100)
  testData.value[1].data = data.map(() => getRandomNum(1, 90) / 100)
  testData.value[2].data = data.map(() => getRandomNum(1, 100))
  testData.value[3].data = data.map(() => getRandomNum(1, 45))
  testData.value[4].data = data.map(() => getRandomNum(30, 95))

  const option = getInitChartOption(testData.value, dateList)
  // 使用刚指定的配置项和数据显示图表。
  rankChart.value = option
  const optionSlider = getInitChartSliderOption(testData.value, dateList)
  rankChartSlider.value = optionSlider
  setTimeout(() => {
    nextTick(() => {
      const mainChart = mapChart.value?.chart
      const sliderChart = mapChartSlider.value?.chart
      console.log(mainChart, 'mainChart---------------->', sliderChart)
      // 监听 control 图表的 datazoom 事件,并同步到 main 图表
      // echarts.connect([sliderChart, mainChart])
      syncDataZoom(sliderChart, mainChart)
      mainChart.setOption({
        dataZoom: [
          {
            start: 80,
            end: 100,
            top: '100%'
          },
          {
            start: 80,
            end: 100
          }
        ]
      })
    })
  }, 1000)
}

function syncDataZoom(fromChart: any, toChart: any) {
  fromChart.on('datazoom', (params: any) => {
    toChart.setOption({
      dataZoom: [
        {
          start: params.start,
          end: params.end,
          top: '100%'
        },
        {
          start: params.start,
          end: params.end
        }
      ]
    })
  })
}
</script>

<style scoped lang="scss">
.device-echarts {
  width: 100%;
  margin-top: height(20);
  .scroll-box {
    max-height: height(750);
    // height: height(800);
    overflow: auto;
    // 滚动条样式
    &::-webkit-scrollbar {
      width: 5px;
      height: 5px;
      background-color: transparent;
    }
    &::-webkit-scrollbar-thumb {
      background-color: #2e5d5f;
      border-radius: 5px;
    }
    .echart-wrapper {
      width: 100%;
      height: v-bind(chartHeight);
      // height: 1600px; // 调试测试数据开启
      position: relative;
    }
  }
  .echart-box {
    width: 100%;
    // height: v-bind(chartHeight);
    // height: 1600px;
    // height: height(1600);
    // height: height(2000);
    position: relative;
  }
  .echart-slider {
    width: 100%;
    height: 43px;
    overflow: hidden;
  }
  .echart {
    width: 100%;
    height: 100%;
    // height: v-bind(chartHeight);
    // height: height(1600);
  }
  .title-item {
    position: absolute;
    top: height(70);
    // top: height(450);
    // top: height(830);
    left: 50px;
    display: flex;
    align-items: center;
    > img {
      width: 27px;
      height: 24px;
      margin-right: 17px;
    }
    > span {
      font-family:
        Alibaba PuHuiTi,
        Alibaba PuHuiTi;
      font-weight: 500;
      font-size: 20px;
      text-align: left;
      font-style: normal;
      text-transform: none;
      background: linear-gradient(90deg, #ffffff 0%, #e2e9ff 100%);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
  }
}
</style>

End

有问题可以留言,看见回回复的