Echarts-丝带图

Echarts-丝带图

demo地址

打开CodePen

什么是丝带图?

丝带图是Power BI中独有额可视化视觉对象,它的工具提示能展示指标当期与下期的数据以及排名。需求:使用丝带图展示"2022年点播订单表"不同月份不同点播套餐对应订单数据。

效果

思路

由于丝带图是Power BI中独有额可视化视觉对象,所以目前没得任何示例参考,所以只能自己构思使用echarts还原了。当然还有完善的余地,中间的连线不够平滑,可根据产品需求采用某种曲线函数去生成一组点位。

markdown 复制代码
1. 以散点图画出柱状堆叠效果(柱状图的堆叠图无法满足hover小块效果)
  - y轴分成100个刻度,每个刻度代表1%,以控制大数据视图效果
2. 在柱状图两根柱之间构建6个点,使用面积图,连接2块柱
  - 柱中间点位取的是y轴的平均值
  - (若想构建的曲线细腻,可以使用曲线函数来构建这部分的点)
3. 再使用上面6个点中的下面点绘制透明区域

核心代码

  • 以散点图构建柱状图
js 复制代码
function createOption(initData) {
  const initDataResult = createData(initData);
  const { list, legendData, xAxisData, seriesDataMap, max } = initDataResult;
  const seriesData = [];

  for (const seriesIndex in Object.keys(seriesDataMap)) {
    const name = Object.keys(seriesDataMap)[seriesIndex];
    const data = seriesDataMap[name];
    seriesData.push({
      name,
      type: 'scatter',
      symbol: 'rect',
      z: 3,
      itemStyle: {
        opacity: 1
      },
      label: {
        show: true,
        color: '#fff',
        formatter: (params) => formatMoney(params.data.realValue, 0)
      },
      tooltip: {
        trigger: 'item',
        formatter: (params) => {
          return `<div>
          <div>年度月份:${params.name}</div>
          <div>${params.seriesName}:${formatMoney(params.data.realValue, 0)}</div>
        </div>`;
        }
      },
      data: getChartData({ data, name })
    });
  }

  function getChartData({ data = [], name }) {
    const dataResult = [];
    data?.forEach((value, dateIndex) => {
      const y = maxY * (value / max);
      const ySize = maxHeight * (y / maxY);
      const offset = getOffset({ list, dateIndex, name, max });
      const radioValue = y + offset > 100 ? 100 : y + offset;

      dataResult.push({
        name,
        value: radioValue,
        radioValue,
        realValue: value,
        symbolOffset: [0, '50%'],
        symbolSize: [50, ySize]
      });

      if (dateIndex < data?.length - 1) {
        new Array(3).fill(0).forEach((_, lineIndex) => {
          dataResult.push({
            value: '',
            radioValue,
            realValue: value,
            isLine: true,
            lineIndex
          });
        });
      }
    });
    return dataResult;
  }

  const lineSeries = createLineChart({ seriesData, initDataResult });

  return {
    option: {
      legend: {
        data: legendData
      },
      xAxis: {
        data: xAxisData,
        axisTick: {
          show: false
        }
      },
      series: [...seriesData, ...lineSeries]
    }
  };
}
  • 生成折线图数据
js 复制代码
function getLineData(data, name, isSpace = false) {
  const result = data?.map((_, index) => {
    const dateIndex = Math.floor(index / 4);
    const lineIndex = index % 4;

    const item = data?.[index] || {};
    const lastItem = data?.[index - (4 - lineIndex)] || {};
    const nextItem = data?.[index + (4 - lineIndex)] || {};

    const offset = getOffset({ list, dateIndex, name, max });
    const nextOffset = getOffset({ list, dateIndex: dateIndex + 1, name, max });
    let spaceValue;
    let value = item.radioValue - offset;

    switch (lineIndex) {
      case 0:
        spaceValue = offset;
        break;
      case 1:
        spaceValue = offset;
        if (!nextItem?.radioValue) {
          value = undefined;
        }
        break;
      case 2:
        spaceValue = (nextOffset + offset) / 2;
        value = (nextItem.radioValue + item.radioValue) / 2 - spaceValue;
        break;
      case 3:
        spaceValue = nextOffset;
        value = nextItem.radioValue - nextOffset;
        if (!lastItem?.radioValue) {
          value = undefined;
        }
        break;
    }
    if (!lastItem?.radioValue && !nextItem?.radioValue) {
      value = undefined;
    }
    // console.log(lineIndex, item, offset, nextOffset, spaceValue, value);
    const newItem = {
      ...item,
      value: isSpace ? spaceValue : value
    };
    return newItem;
  });
  // console.log('result', result);
  return result;
}
  • 生成折线图配置
js 复制代码
function createLineChart({ seriesData = [], initDataResult }) {
  const { list, max } = initDataResult;
  const spaceLineSeries = [];
  const lineSeries = [];
  // console.log('seriesData', seriesData);
  for (const seriesIndex in seriesData) {
    const seriesItem = seriesData[seriesIndex];
    const defaultLineSeries = {
      type: 'line',
      name: seriesItem.name,
      stack: `Line-${seriesIndex}`,
      smooth: 0.3,
      lineStyle: {
        width: 0,
        opacity: 0
      },
      symbol: 'none',
      showSymbol: false,
      triggerLineEvent: true,
      silent: true,
      areaStyle: {},
      emphasis: {
        focus: 'series'
      }
    };

    spaceLineSeries.push({
      ...defaultLineSeries,
      areaStyle: {
        opacity: 0
      },
      data: getLineData(seriesItem?.data, seriesItem.name, true)
    });

    lineSeries.push({
      ...defaultLineSeries,
      data: getLineData(seriesItem?.data, seriesItem.name)
    });
  }

  function getLineData(data, name, isSpace = false) {
    const result = data?.map((_, index) => {
      const dateIndex = Math.floor(index / 4);
      const lineIndex = index % 4;

      const item = data?.[index] || {};
      const lastItem = data?.[index - (4 - lineIndex)] || {};
      const nextItem = data?.[index + (4 - lineIndex)] || {};

      const offset = getOffset({ list, dateIndex, name, max });
      const nextOffset = getOffset({ list, dateIndex: dateIndex + 1, name, max });
      let spaceValue;
      let value = item.radioValue - offset;

      switch (lineIndex) {
        case 0:
          spaceValue = offset;
          break;
        case 1:
          spaceValue = offset;
          if (!nextItem?.radioValue) {
            value = undefined;
          }
          break;
        case 2:
          spaceValue = (nextOffset + offset) / 2;
          value = (nextItem.radioValue + item.radioValue) / 2 - spaceValue;
          break;
        case 3:
          spaceValue = nextOffset;
          value = nextItem.radioValue - nextOffset;
          if (!lastItem?.radioValue) {
            value = undefined;
          }
          break;
      }
      if (!lastItem?.radioValue && !nextItem?.radioValue) {
        value = undefined;
      }
      // console.log(lineIndex, item, offset, nextOffset, spaceValue, value);
      const newItem = {
        ...item,
        value: isSpace ? spaceValue : value
      };
      return newItem;
    });
    // console.log('result', result);
    return result;
  }

  return [...spaceLineSeries, ...lineSeries];
}

完整代码

js 复制代码
var dom = document.getElementById('chart-container');
var myChart = echarts.init(dom, null, {
  renderer: 'canvas',
  useDirtyRect: false
});
var app = {};

var option;

const defaultData = [
  {
    date: '2022年02月',
    list: [
      {
        name: '安列克-常州四药',
        value: 48196
      },
      {
        name: '贝克宁-成都贝特',
        value: 85944
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 43122
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 46082
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 28473
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 20584
      }
    ]
  },
  {
    date: '2022年03月',
    list: [
      {
        name: '安列克-常州四药',
        value: 97775
      },
      {
        name: '贝克宁-成都贝特',
        value: 134262
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 102538
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 77479
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 59422
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 32413
      }
    ]
  },
  {
    date: '2022年04月',
    list: [
      {
        name: '安列克-常州四药',
        value: 91399
      },
      {
        name: '贝克宁-成都贝特',
        value: 151064
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 74733
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 75197
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 46853
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 24845
      }
    ]
  },
  {
    date: '2022年05月',
    list: [
      {
        name: '安列克-常州四药',
        value: 83667
      },
      {
        name: '贝克宁-成都贝特',
        value: 114716
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 57327
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 62267
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 38604
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 19766
      }
    ]
  },
  {
    date: '2022年06月',
    list: [
      {
        name: '安列克-常州四药',
        value: 80524
      },
      {
        name: '贝克宁-成都贝特',
        value: 155227
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 67098
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 61857
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 44098
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 26956
      }
    ]
  },
  {
    date: '2022年07月',
    list: [
      {
        name: '安列克-常州四药',
        value: 92172
      },
      {
        name: '贝克宁-成都贝特',
        value: 118129
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 61548
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 64490
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 38073
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 21705
      }
    ]
  },
  {
    date: '2022年08月',
    list: [
      {
        name: '安列克-常州四药',
        value: 94615
      },
      {
        name: '贝克宁-成都贝特',
        value: 119397
      },
      {
        name: '瀚宝-深圳瀚宇',
        value: 60547
      },
      {
        name: '卡贝缩宫素-杭州澳亚',
        value: 73835
      },
      {
        name: '卡贝缩宫素-天吉生物',
        value: 37406
      },
      {
        name: '卡贝缩宫素-星银药业',
        value: 26228
      }
    ]
  }
]

function formatMoney(money) {
   return money
}

function run({ data = defaultData, height = 500 }) {
  const chartHeight = height;
  const maxY = 100;
  const maxHeight = chartHeight - maxY;

  function createData(initData = []) {
    const list = initData?.map((item) => ({
      ...item,
      total: item.list.reduce((pre, cur) => pre + cur.value, 0),
      list: item.list?.sort((a, b) => a.value - b.value)
    }));
    const legendData = [];
    const xAxisData = [];
    const seriesDataMap = {};
    let max = 0;

    // 生成x轴、图例数据
    for (const dateIndex in list) {
      const item = list[dateIndex];
      xAxisData.push(item.date);
      if (dateIndex < list?.length - 1) {
        new Array(3).fill(0).forEach((_, lineIndex) => {
          xAxisData.push(`line-${lineIndex}`);
        });
      }
      max = Math.max(max, item.total);
      for (const index in item.list) {
        const dataItem = item.list[index];
        if (!legendData?.includes(dataItem.name)) {
          legendData.push(dataItem.name);
        }
      }
    }

    // 根据图例生成数据
    for (const index in list) {
      const item = list[index];
      for (const name of legendData) {
        const dataItem = item?.list?.find((dataItem) => dataItem.name === name);
        _.set(seriesDataMap, `${name}.${index}`, dataItem?.value);
      }
    }

    const result = { list, legendData, xAxisData, seriesDataMap, max };
    // console.log('result', result);
    return result;
  }

  function createLineChart({ seriesData = [], initDataResult }) {
    const { list, max } = initDataResult;
    const spaceLineSeries = [];
    const lineSeries = [];
    // console.log('seriesData', seriesData);
    for (const seriesIndex in seriesData) {
      const seriesItem = seriesData[seriesIndex];
      const defaultLineSeries = {
        type: 'line',
        name: seriesItem.name,
        stack: `Line-${seriesIndex}`,
        smooth: 0.3,
        lineStyle: {
          width: 0,
          opacity: 0
        },
        symbol: 'none',
        showSymbol: false,
        triggerLineEvent: true,
        silent: true,
        areaStyle: {},
        emphasis: {
          focus: 'series'
        }
      };

      spaceLineSeries.push({
        ...defaultLineSeries,
        areaStyle: {
          opacity: 0
        },
        data: getLineData(seriesItem?.data, seriesItem.name, true)
      });

      lineSeries.push({
        ...defaultLineSeries,
        data: getLineData(seriesItem?.data, seriesItem.name)
      });
    }

    function getLineData(data, name, isSpace = false) {
      const result = data?.map((_, index) => {
        const dateIndex = Math.floor(index / 4);
        const lineIndex = index % 4;

        const item = data?.[index] || {};
        const lastItem = data?.[index - (4 - lineIndex)] || {};
        const nextItem = data?.[index + (4 - lineIndex)] || {};

        const offset = getOffset({ list, dateIndex, name, max });
        const nextOffset = getOffset({ list, dateIndex: dateIndex + 1, name, max });
        let spaceValue;
        let value = item.radioValue - offset;

        switch (lineIndex) {
          case 0:
            spaceValue = offset;
            break;
          case 1:
            spaceValue = offset;
            if (!nextItem?.radioValue) {
              value = undefined;
            }
            break;
          case 2:
            spaceValue = (nextOffset + offset) / 2;
            value = (nextItem.radioValue + item.radioValue) / 2 - spaceValue;
            break;
          case 3:
            spaceValue = nextOffset;
            value = nextItem.radioValue - nextOffset;
            if (!lastItem?.radioValue) {
              value = undefined;
            }
            break;
        }
        if (!lastItem?.radioValue && !nextItem?.radioValue) {
          value = undefined;
        }
        // console.log(lineIndex, item, offset, nextOffset, spaceValue, value);
        const newItem = {
          ...item,
          value: isSpace ? spaceValue : value
        };
        return newItem;
      });
      // console.log('result', result);
      return result;
    }

    return [...spaceLineSeries, ...lineSeries];
  }

  function createOption(initData) {
    const initDataResult = createData(initData);
    const { list, legendData, xAxisData, seriesDataMap, max } = initDataResult;
    const seriesData = [];

    for (const seriesIndex in Object.keys(seriesDataMap)) {
      const name = Object.keys(seriesDataMap)[seriesIndex];
      const data = seriesDataMap[name];
      seriesData.push({
        name,
        type: 'scatter',
        symbol: 'rect',
        z: 3,
        itemStyle: {
          opacity: 1
        },
        label: {
          show: true,
          color: '#fff',
          formatter: (params) => formatMoney(params.data.realValue, 0)
        },
        tooltip: {
          trigger: 'item',
          formatter: (params) => {
            return `<div>
            <div>年度月份:${params.name}</div>
            <div>${params.seriesName}:${formatMoney(params.data.realValue, 0)}</div>
          </div>`;
          }
        },
        data: getChartData({ data, name })
      });
    }

    function getChartData({ data = [], name }) {
      const dataResult = [];
      data?.forEach((value, dateIndex) => {
        const y = maxY * (value / max);
        const ySize = maxHeight * (y / maxY);
        const offset = getOffset({ list, dateIndex, name, max });
        const radioValue = y + offset > 100 ? 100 : y + offset;

        dataResult.push({
          name,
          value: radioValue,
          radioValue,
          realValue: value,
          symbolOffset: [0, '50%'],
          symbolSize: [50, ySize]
        });

        if (dateIndex < data?.length - 1) {
          new Array(3).fill(0).forEach((_, lineIndex) => {
            dataResult.push({
              value: '',
              radioValue,
              realValue: value,
              isLine: true,
              lineIndex
            });
          });
        }
      });
      return dataResult;
    }

    const lineSeries = createLineChart({ seriesData, initDataResult });

    return {
      option: {
        legend: {
          data: legendData
        },
        xAxis: {
          data: xAxisData,
          axisTick: {
            show: false
          }
        },
        series: [...seriesData, ...lineSeries]
      }
    };
  }

  function getOffset({ list, dateIndex, name, max }) {
    const dateData = list[dateIndex]?.list || [];
    const itemIndex = dateData?.findIndex((item) => item.name === name);

    let offset = 0;
    for (let i = 0; i < itemIndex; i++) {
      const itemValue = dateData[i].value;
      offset += maxY * (itemValue / max);
    }

    return offset;
  }

  const { option: newOption } = createOption(data);

  return _.merge(
    {
      grid: {
        top: 40,
        left: 20,
        right: 20,
        bottom: 40,
        containLabel: true
      },
      yAxis: {
        show: false,
        max: maxY
      },
      tooltip: {
        // show: true,
        // trigger: 'axis',
        // axisPointer: {
        //   type: 'none'
        // },
        // formatter: (params, ticket) => {
        //   // console.log('params', params, ticket);
        //   return '';
        // }
      },
      dataZoom: [
        {
          type: 'slider',
          filterMode: 'weakFilter',
          showDataShadow: false,
          showDetail: false,
          brushSelect: false,
          height: 20,
          bottom: 10,
          startValue: 1,
          endValue: 5,
          xAxisIndex: 0,
          start: 0,
          end: 100
        }
      ],
      xAxis: {
        type: 'category',
        data: newOption.xAxis.data,
        axisLabel: {
          formatter: function (value) {
            return value?.includes('line') ? '' : value;
          }
        }
      }
    },
    newOption
  );
}

function getOption(data, height) {
  return run({ data, height });
}

option = getOption(defaultData);

if (option && typeof option === 'object') {
  myChart.setOption(option);
}

window.addEventListener('resize', myChart.resize);
相关推荐
喵叔哟19 分钟前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特1 小时前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解1 小时前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~1 小时前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding1 小时前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT1 小时前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓1 小时前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶21361 小时前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了1 小时前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕1 小时前
Django 搭建数据管理web——商品管理
前端·python·django