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

商品二与商品一的销量差值
但是,目前存在的问题是echarts官方并没有提供这样的series和示例,在网上搜了一圈也没有找到什么实现方案,经过我废寝忘食地研究,得出了三种实现方法。
方法一:通过折线图的areaStyle实现
这个方法是利用两个折线图之间areaStyle的覆盖关系实现,只需要一个设置白色,一个设置其他颜色,非常简单,但是局限性很大,有以下几点:
- 面积范围不能设置透明度,否则覆盖效果不完全
- 需要禁用相关的事件,否则鼠标悬浮时会优先展示悬浮的折线图
- 坐标轴的分隔线需要隐藏,否则白色面积会遮盖住部分分隔线
- 只能设置一个白色一个其他颜色,即无法体现正负差值
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提供的功能来生成,可以进行一些交互,不过对脑力要求更高,因为要自己去算交点,下面简单推导下计算原理:
- 我们假设折线图的每一段都是一条独立的直线,那我们知道两条不平行的直线肯定是会有交点的,通过计算每一段的交点并且判断交点的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
- 得到交点后,以交点为分界,可以得出两折线图的差值范围,交点两侧的范围必定是相反意义的,即左侧是折线图一>折线图二,右侧必定是折线图二>折线图一,根据这个特性,我们用一个数组去储存范围,可以通过单双数索引区分其差值意义。
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进行交流。