细嗦echarts实现面积范围图

前言

关于面积范围图,指的是两折线图之间的面积范围,不过不是简单的将它们之间所形成的区域进行填充,而是两折线图之间是有差值意义,即所要展示的内容是折线图一与折线图二的差值范围。举个例子,假设有两个商品,现在统计了它们一年内每个月的销量,现在如果想要将他们的销量进行对比,就可以通过面积范围图很直观地展示出它们的销量差值,如下图:

商品二与商品一的销量差值

但是,目前存在的问题是echarts官方并没有提供这样的series和示例,在网上搜了一圈也没有找到什么实现方案,经过我废寝忘食地研究,得出了三种实现方法。

方法一:通过折线图的areaStyle实现

这个方法是利用两个折线图之间areaStyle的覆盖关系实现,只需要一个设置白色,一个设置其他颜色,非常简单,但是局限性很大,有以下几点:

  1. 面积范围不能设置透明度,否则覆盖效果不完全
  2. 需要禁用相关的事件,否则鼠标悬浮时会优先展示悬浮的折线图
  3. 坐标轴的分隔线需要隐藏,否则白色面积会遮盖住部分分隔线
  4. 只能设置一个白色一个其他颜色,即无法体现正负差值
js 复制代码
option = {
  tooltip: {
    trigger: 'axis'
  },
  legend: {},
  grid: {
    left: '3%',
    right: '4%',
    bottom: '3%',
    containLabel: true
  },
  xAxis: {
    type: 'category',
    boundaryGap: false,
    data: new Array(12).fill(0).map((_,index)=>`${index+1}月`)
  },
  yAxis: {
    type: 'value',
    name: '销量/万',
    splitLine: {show: false}
  },
  series: [
    {
      name: '商品一',
      type: 'line',
      data: [120, 132, 101, 134, 90, 230, 210, 280, 300, 320, 220, 230],
      areaStyle: {color: '#fff',opacity: 1},
    },
    {
      name: '商品二',
      type: 'line',
      data: [220, 182, 191, 234, 290, 330, 310, 290, 280, 290, 300, 320],
      areaStyle: {color: '#ff0000',opacity:1},
    },
  ]
};

方法二:自定义一个canvas来画面积范围

这个方法是自定义一个canvas,分别画出两条折线图到坐标轴的面积,然后将混合模式改为xor(删除重合部分),就可以得出我们想要的面积范围。这个方法相对于方法一的好处是可以设置不同颜色,设置透明度并且不会影响原echarts图的任何交互。

为什么要定义一个独立的canvas?

因为这个方法利用的是混合模式,而混合模式是会影响全局的,如果仍然用echarts的canvas,则会对echarts的效果造成很大影响。

tsx 复制代码
  const chart: any = useRef();

  useEffect(() => {
    const chartInstance = chart?.current?.getEchartsInstance();
    const data1 = [120, 132, 101, 134, 90, 230, 210, 280, 300, 320, 220, 230]
    const data2 = [220, 182, 191, 234, 290, 330, 310, 290, 280, 290, 300, 320]
    chartInstance.setOption({
      grid: {
        left: '3%',
        right: '4%',
        bottom: '3%',
        containLabel: true,
      },
      xAxis: {
        type: 'category',
        boundaryGap: false,
        data: new Array(12).fill(0).map((_, index) => `${index + 1}月`),
      },
      yAxis: {
        type: 'value',
        name: '销量/万',
      },
      series: [
        {
          name: '商品一',
          type: 'line',
          data: data1,
        },
        {
          name: '商品二',
          type: 'line',
          data: data2,
        },
      ],
    });
    const canvas = document.querySelector('#canvas');
    const ctx: CanvasRenderingContext2D = canvas!.getContext('2d');
    const scale = window.devicePixelRatio;
    canvas.width = scale * canvas?.clientWidth;
    canvas.height = scale * canvas?.clientHeight;

    const draw = (ctx, points, color) => {
      ctx.fillStyle = color;
      ctx.moveTo(points[0][0], points[0][1]);
      ctx.beginPath();
      points.slice(1).forEach((item) => {
        ctx.lineTo(item[0], item[1]);
      });
      ctx.lineTo(points[0][0], points[0][1]);
      ctx.fill();
      ctx.closePath();
    };

    const genPoints = (data: number[]) => {
      // 将series的坐标转换为像素坐标
      const points = data.map((item, index) => {
        return chartInstance.convertToPixel({ seriesIndex: 0 }, [index, item]);
      });
      // 首尾节点在坐标轴上的投影位置,形成一个封闭区域
      points.push(
        chartInstance.convertToPixel({ seriesIndex: 0 }, [data.length - 1, 0]),
        chartInstance.convertToPixel({ seriesIndex: 0 }, [0, 0]),
      );
      return points;
    };
    const points1 = genPoints(data1);
    const points2 = genPoints(data2);
    ctx.globalCompositeOperation = 'xor';
    draw(ctx, points2, 'red');
    draw(ctx, points1, '#fff');
  }, []);

  return (
    <div style={{ position: 'relative', width: 1102, height: 761, background: '#fff' }}>
      <div
        style={{
          height: '100%',
          width: '100%',
          position: 'absolute',
          zIndex: 10,
          opacity: 0.3,
          pointerEvents: 'none',
        }}
      >
        <canvas id="canvas" style={{ width: '100%', height: '100%' }}></canvas>
      </div>
      <EChartsReact
        option={{}}
        ref={chart}
        style={{ height: '100%',width: '100%' }}
      ></EChartsReact>
    </div>
  );

方法三:利用echarts的自定义图形来画范围

这个方案需要手动计算出两个折线图的包裹区域,然后通过echarts的自定义图形将所有区域画出来。这个方法相比于方法二,好处是直接通过echarts提供的功能来生成,可以进行一些交互,不过对脑力要求更高,因为要自己去算交点,下面简单推导下计算原理:

  1. 我们假设折线图的每一段都是一条独立的直线,那我们知道两条不平行的直线肯定是会有交点的,通过计算每一段的交点并且判断交点的x坐标在两段直线的两端之间,就可以得知这个是两个折线图的交点。
js 复制代码
// 假设直线的两端坐标
const x1, y1, x2, y2
// 直线
y = a * x + b
// 求一元一次方程
y1 = a * x1 + b
y2 = a * x2 + b
a * (x2 - x1) = y2 - y1
a = (y2 - y1) / (x2 - x1)
b = y1 - x1 * (y2 - y1) / (x2 - x1)
// 求得直线为
y = (y2 - y1) * x /(x2 - x1) + y1 - x1 * (y2 - y1) / (x2 - x1)

// 假设两条直线
y = a1 * x + b1
y = a2 * x + b2
// 求交点
a1 * x + b1 = a2 * x + b2
(a1 - a2) * x = b2 - b1
x = (b2 - b1) / (a1 - a2)
y = a1 * (b2 - b1) / (a1 - a2) + b1
  1. 得到交点后,以交点为分界,可以得出两折线图的差值范围,交点两侧的范围必定是相反意义的,即左侧是折线图一>折线图二,右侧必定是折线图二>折线图一,根据这个特性,我们用一个数组去储存范围,可以通过单双数索引区分其差值意义。
ts 复制代码
  useEffect(() => {
    const chartInstance = chart?.current?.getEchartsInstance();
    const genPoints = (data: number[]) => {
      // 将series的坐标转换为像素坐标
      const points = data.map((item, index) => {
        return chartInstance.convertToPixel({ seriesIndex: 0 }, [index, item]);
      });
      return points;
    };
    const data1 = [120, 132, 101, 134, 90, 230, 210, 280, 300, 320, 220, 230];
    const data2 = [220, 182, 191, 234, 290, 330, 310, 290, 280, 290, 300, 320];

    chartInstance.setOption(
      {
        tooltip: {
          trigger: 'axis'
        },
        xAxis: {
          type: 'category',
          boundaryGap: false,
          data: new Array(12).fill(0).map((_, index) => `${index + 1}月`),
        },
        yAxis: {
          type: 'value',
          name: '销量/万',
        },
        series: [
          {
            name: '商品一',
            type: 'line',
            data: data1,
          },
          {
            name: '商品二',
            type: 'line',
            data: data2,
          },
        ],
      },
      true,
    );
    const points1 = genPoints(data1);
    const points2 = genPoints(data2);
    // 根据坐标点求方程参数
    const genFnParams = (x1: number, y1: number, x2: number, y2: number) => {
      const a = (y2 - y1) / (x2 - x1);
      return { a, b: y1 - a * x1 };
    };

    // 范围储存数组
    const scope: number[][] = [[]];
    for (let i = 0; i < points1.length - 1; i++) {
      const { a: a1, b: b1 } = genFnParams(
        points1[i][0],
        points1[i][1],
        points1[i + 1][0],
        points1[i + 1][1],
      );
      const { a: a2, b: b2 } = genFnParams(
        points2[i][0],
        points2[i][1],
        points2[i + 1][0],
        points2[i + 1][1],
      );
      // 交点
      const x = (b2 - b1) / (a1 - a2);
      const y = a1 * x + b1;
      scope[scope.length - 1].push(points1[i]);
      scope[scope.length - 1].unshift(points2[i]);
      // 判断交点是否在两端点之间,是就记录进范围
      if (x > points1[i][0] && x < points1[i + 1][0]) {
        scope[scope.length - 1].push([x, y]);
        scope.push([[x, y]]);
      }
    }
    // 折线图最后一点
    scope[scope.length - 1].push(points1[points1.length - 1]);
    scope[scope.length - 1].unshift(points2[points2.length - 1]);
    // 更新图表
    chartInstance.setOption({
      series: [
        {
          name: '商品一',
          type: 'line',
          data: data1,
        },
        {
          name: '商品二',
          type: 'line',
          data: data2,
        },
        ...scope.map(item=>({
          type: 'custom',
          renderItem: function (params, api) {
            if (params.context.rendered) {
              return;
            }
            params.context.rendered = true;
            let color = api.visual('color');
            return {
              type: 'polygon',
              transition: ['shape'],
              shape: {
                points: item,
              },
              style: api.style({
                fill: color,
                stroke: echarts.color.lift(color, 0.1),
                opacity: 0.5,
              }),
            };
          },
          // 这里需要将y坐标转换回echarts的坐标系数值,echarts根据这里的数值确定y轴范围
          data: item.map(ele=>chartInstance.convertFromPixel({seriesIndex: 0}, ele)[1]),
        })),
      ],
    });
  }, []);

最后

如果觉得文章对你有帮助,求点赞求关注!!

有其他更好的方法的话可以给我留言或是加我vx:Soundmark进行交流。

相关推荐
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19953 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安7 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光8 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   8 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   8 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web8 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery