使用 ECharts 创建自定义 3D 柱状图
date
Nov 17, 2022
slug
use-echarts-create-3d-bars
status
Published
tags
ECharts
summary
深度了解、使用 ECharts 自定义图形
type
Post
前置知识
本文基于2d坐标系讲解,极坐标/3d坐标系不在范围内
ECharts 简介
ECharts 如何自定义图形
ECharts 自定义图形中常用概念与方法
ECharts 文档描述过于抽象🤷♂️,下面我用具象的例子来阐述。
维度(dimension)
在常见的水平图表(y轴代表数据,x轴代表数据含义)中,第一个维度即x轴数值,第二个维度即y轴数值;而在垂直图表(x轴代表数据,y轴代表数据含义)中,第一个维度即x轴数值,第二个维度即y轴数值。
总结:类比二维记忆,不论什么方向均从x轴开始。
类目(catagroy)/ 数据项(dataItem)
这俩个指的是一个东西,在水平图表中x轴代表类目。在垂直图表中y轴代表类目。
总结:本文记作类目,说白了指的是数据含义
系列(series)
在水平图表中y轴代表系列。在垂直图表中x轴代表系列。
总结:说白了指的是数据
api.value
看了很多示例,
api.value(0)
api.value(1)
api.value(2)
看得一头雾水。其实0
代表第一个维度,而api.value(0)
获得的值(即类目)在水平图表中指x轴数值,垂直图表中指y轴数值;1
代表第二个维度,而api.value(1)
获得的值(即系列)在水平图表中指y轴数值,垂直图表中指x轴数值。总结:根据
api.value
来获得某个点的坐标数据api.coord
这个方法一般和
api.value
同时出现,即通过api.value
获得某个点的坐标数据。注意是坐标数据,而不是真正的网页坐标。下一步把坐标数据传入api.coord
即可获得该点的网页坐标。例如:api.coord([api.value(0), api.value(1)])
params
在业务过程中为了方便,我们不论是开发水平图表还是垂直图表都使用一套数据源,只更改
xAxis
与yAxis
的配置即可。我们可以通过params
来获取类目与系列:{
// ...
renderItem(params, api) {
const catagroyIndex = params.dataIndex;
const seriesIndex = params.seriesIndex;
const seriesTopPoint = [catagroyIndex, seriesIndex]
const seriesBottomPoint = [catagroyIndex, 0]
const seriesTopLocation = api.coord(seriesTopPoint)
const seriesBottomLocation = api.coord(seriesBottomPoint)
// ...
}
// ...
}
实现 3d 柱状图堆叠
实现一个 3d 柱状图
// 绘制左侧面
const wid = 30;
const w1 = Math.sin(Math.PI / 6) * wid; //4
const w2 = Math.sin(Math.PI / 3) * wid; // 6.8
const snapHeight = wid / 2;
const CubeLeft = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x, shape.y];
const c1 = [shape.x - w2, shape.y];
const c2 = [shape.x - w2, xAxisPoint[1]];
const c3 = [shape.x, xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).closePath();
},
});
// 绘制右侧面
const CubeRight = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x, shape.y];
const c2 = [shape.x, xAxisPoint[1]];
const c3 = [shape.x + w1, xAxisPoint[1] - w2 + snapHeight];
const c4 = [shape.x + w1, shape.y - w2 + snapHeight];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1]).closePath();
},
});
// 绘制顶面
const CubeTop = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath: function (ctx, shape) {
//
const c1 = [shape.x, shape.y];
const c2 = [shape.x + w1, shape.y - w2 + snapHeight]; //右点
const c3 = [shape.x - w2 + w1, shape.y - w2 + snapHeight];
const c4 = [shape.x - w2, shape.y];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1]).closePath();
},
});
// 三个面图形
echarts.graphic.registerShape('CubeLeft', CubeLeft);
echarts.graphic.registerShape('CubeRight', CubeRight);
echarts.graphic.registerShape('CubeTop', CubeTop);
let xData = ['2017', '2018', '2019', '2020', '2021'];
let yData = [150, 126, 260, 220, 184];
var option = {
backgroundColor: '#000a3f',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'rgba(9, 24, 48, 0.5)',
borderColor: 'rgba(75, 253, 238, 0.4)',
textStyle: {
color: '#CFE3FC',
},
borderWidth: 1,
formatter: function (params) {
let item = '';
item += params[0].name;
$(params).each(function (idx, itm) {
item += ': ' + itm.value+'万元';
});
return item;
},
},
grid: {
top: '10%',
left: '5%',
bottom: '5%',
right: '5%',
containLabel: true,
},
xAxis: {
type: 'category',
data: xData,
axisLine: {
show: true,
lineStyle: {
color: '#3e6f8e',
width: 1,
},
},
axisTick: {
show: false,
length: 9,
alignWithLabel: false,
lineStyle: {
color: '#AAA',
},
},
axisLabel: {
fontSize: 14,
margin: 10,
color: 'white',
},
splitLine: {
show: false,
lineStyle: {
color: '#ffffff',
opacity: 0.2,
width: 1,
},
},
},
yAxis: {
name: '单位:万元',
type: 'value',
nameTextStyle: {
color: 'white',
fontSize: 16,
},
axisLine: {
show: true,
lineStyle: {
color: '#3e6f8e',
width: 1,
},
},
axiosTick: {
show: false,
},
axisLabel: {
color: 'white',
fontSize: 14,
margin: 10,
},
splitLine: {
show: true,
lineStyle: {
color: '#ffffff',
opacity: 0.2,
width: 1,
},
},
nameGap: 20,
},
series: [
{
type: 'bar',
label: {
normal: {
show: true,
position: 'top',
fontSize: 16,
color: '#fff',
offset: [0, -10],
},
},
tooltip: {
show: false,
},
itemStyle: {
color: 'transparent',
},
data: yData,
},
{
type: 'custom',
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
location[0] = location[0] + wid * 0;
const xlocation = api.coord([api.value(0), 0]);
xlocation[0] = xlocation[0] + wid * 0;
return {
type: 'group',
children: [
{
type: 'CubeLeft',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: xlocation,
},
style: {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#059de6',
},
{
offset: 1,
color: '#059de6',
},
]),
},
},
{
type: 'CubeRight',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: xlocation,
},
style: {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#254193',
},
{
offset: 1,
color: '#254193',
},
]),
},
},
{
type: 'CubeTop',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: xlocation,
},
style: {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#17e0fe',
},
{
offset: 1,
color: '#17e0fe',
},
]),
},
},
],
};
},
color: 'blue',
data: yData,
},
],
};
详情可查看:
快速重构逻辑
ECharts 的基本使用很熟悉,这里需要快速查阅未使用过的
registerShape
、extendShape
、custom
等。- 为了方便扩展与维护,可以把自定义图形绘制的逻辑与业务代码拆开。因为图形绘制只与传入的坐标和样式有关。
- 提取静态变量、动态变量
- 删除无用逻辑
// 3DBar.ts
import type { graphic, CustomSeriesRenderItemAPI } from 'echarts'
type ExtendShape = typeof graphic.extendShape
type RegisterShape = typeof graphic.registerShape
export interface Shape {
seriesTopLocation: number[]
seriesBottomLocation: number[]
api: CustomSeriesRenderItemAPI
}
/** 3dbar顶部默认宽度(像素)*/
const HORIZONTAL_BAR_TOP_WIDTH = 18;
/** 3dbar顶部默认高度(像素)*/
const HORIZONTAL_BAR_TOP_HEIGHT = 8;
/** 3dbar顶部默认偏移(像素)*/
const HORIZONTAL_BAR_TOP_OFFSET = 2;
/**
* 计算比例
*
* @param api
* @returns
*/
function getScale(api: CustomSeriesRenderItemAPI) {
// 获取一个像素对应的坐标
const point = api.size!([0, 1]);
const y = point[1];
// return y / 1; // 需要等比缩放再放开
return 1;
}
/**
* 根据比例换算数据
*
* @param scale
* @param width
* @param height
* @param offsetX
* @returns
*/
function getScaledBarInfo(
scale: number,
width: number,
height: number,
offsetX: number
) {
return {
width: width * scale,
height: height * scale,
offsetX: offsetX * scale
};
}
/**
* 使用canvas画3d的柱形
*
* 注意⚠️:
* 1. 所画图形还是2d,t/l/b/r 代表顶/左/底/右四个点(把这个面理解为棱形即可,顺时针)
*
* @param extendShape 自定义图形
* @param registerShape 向echarts实例注册自定义图形
*/
export function creator(extendShape: ExtendShape, registerShape: RegisterShape) {
// 绘制顶面
registerShape(
'3DBarTop',
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape: Shape) {
// 不规则正方形
const { seriesTopLocation, api } = shape;
const [x1, y1] = seriesTopLocation;
const scale = getScale(api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
BAR_TOP_WIDTH,
BAR_TOP_HEIGHT,
BAR_TOP_OFFSET
);
const t = [x1, y1];
const l = [x1 - width / 2, y1 + height / 2];
const b = [x1 + offsetX, y1 + height];
const r = [x1 + width / 2 + offsetX, y1 + height / 2];
ctx
.moveTo(t[0], t[1])!
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
})
);
// 绘制左侧面
registerShape(
'3DBarLeft',
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape: Shape) {
const { seriesTopLocation, seriesBottomLocation, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
BAR_TOP_WIDTH,
BAR_TOP_HEIGHT,
BAR_TOP_OFFSET
);
const t = [x1 - width / 2, y1 + height / 2];
const l = [x2 - width / 2, y2 - height / 2];
const b = [x2 + offsetX, y2];
const r = [x1 + offsetX, y1 + height];
ctx
.moveTo(t[0], t[1])!
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
})
);
// 绘制右侧面
registerShape(
'3DBarRight',
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape: Shape) {
const { seriesTopLocation, seriesBottomLocation, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
BAR_TOP_WIDTH,
BAR_TOP_HEIGHT,
BAR_TOP_OFFSET
);
const t = [x1 + offsetX, y1 + height];
const l = [x2 + offsetX, y2];
const b = [x1 + width / 2 + offsetX, y2 - height / 2];
const r = [x1 + width / 2 + offsetX, y1 + height / 2];
ctx
.moveTo(t[0], t[1])!
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
})
);
}
// main.ts
import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams } from 'echarts'
import type { Shape } from './3dBar'
// ...
{
type: 'custom',
renderItem(params: CustomSeriesRenderItemAPI, api: CustomSeriesRenderItemParams) {
const catagroyIndex = params.dataIndex;
const seriesIndex = params.seriesIndex;
const seriesTopPoint = [catagroyIndex, seriesIndex]
const seriesBottomPoint = [catagroyIndex, 0]
const seriesTopLocation = api.coord(seriesTopPoint)
const seriesBottomLocation = api.coord(seriesBottomPoint)
const shape: Shape = {
seriesTopLocation,
seriesBottomLocation,
api
}
return {
type: 'group',
children: [
{
type: '3DBarTop',
shape,
style: {/* ... */}
},
{
type: '3DBarLeft',
shape,
style: {/* ... */}
},
{
type: '3DBarRight',
shape,
style: {/* ... */}
}
]
}
}
}
// ...
用函数式编写堆叠逻辑
主要思路:
- 如何实现堆叠?
- 修改数据实现
- 修改坐标实现
- 如何保证渲染出来一个完整的柱状图?
- 修改数据实现
- 修改坐标实现
- 缓存坐标值
- 数据为0是否渲染?
- 堆叠的柱状图如何实现碰撞检测?
- 如何实现堆叠间隙?
- 如何控制不同方向的渲染
详情如下:
/* :========================================:: direction: "horizontal" ::========================================: */
/** 3dbar顶部默认宽度(像素)*/
const HORIZONTAL_BAR_TOP_WIDTH = 18;
/** 3dbar顶部默认高度(像素)*/
const HORIZONTAL_BAR_TOP_HEIGHT = 8;
/** 3dbar顶部默认偏移(像素)*/
const HORIZONTAL_BAR_TOP_OFFSET = 2;
/** 3dbar默认最小高度(顶点到底点距离,在这个高度能才能完整渲染3dbar)(像素)*/
const HORIZONTAL_BAR_MIN_HEIGHT = HORIZONTAL_BAR_TOP_HEIGHT + HORIZONTAL_BAR_TOP_HEIGHT / 2;
/** 3dbar之间的偏移(像素)*/
const HORIZONTAL_3DBAR_GAP = HORIZONTAL_BAR_TOP_HEIGHT / 2;
/* :========================================:: direction: "horizontal" ::========================================: */
/* :========================================:: direction: "column" ::========================================: */
/** 3dbar顶部默认宽度(像素)*/
const VERTICAL_BAR_TOP_WIDTH = 12;
/** 3dbar顶部默认高度(像素)*/
const VERTICAL_BAR_TOP_HEIGHT = 18;
/** 3dbar顶部默认偏移(像素)*/
const VERTICAL_BAR_TOP_OFFSET = 2;
/** 3dbar默认最小高度(顶点到底点距离,在这个高度能才能完整渲染3dbar)(像素)*/
const VERTICAL_BAR_MIN_WIDTH = VERTICAL_BAR_TOP_WIDTH + VERTICAL_BAR_TOP_WIDTH / 2;
/** 3dbar之间的偏移(像素)*/
const VERTICAL_3DBAR_GAP = VERTICAL_BAR_TOP_WIDTH / 2;
/* :========================================:: direction: "column" ::========================================: */
/* :========================================:: common ::========================================: */
/** stack为false情况下的偏移量(像素)*/
const DEFAULT_NO_STACK_OFFSET = 16;
/** 记录series每次的坐标用于碰撞检测*/
const SERIES_COORD_MAP = new Map<string, any>();
/* :========================================:: common ::========================================: */
/**
* 计算当前维度中当前系列以前的所有系列数值之和
*
* @param useStack
* @param dataset 包含维度/系列组合数值的数据集合,一般为二维数组。一维表示 catagroy,二维表示 series
* @param catagroyIndex 维度索引如:心理所|声学所
* @param seriesIndex 系列索引如:第一次退出|第二次退出
*/
export function fetchPrevSeriesValue(
useStack = true,
dataset: any[],
catagroyIndex: number,
seriesIndex: number
) {
// 获取当前维度serires前的每一项的数值
// 遍历当前bar之前的所有bar,得出当前bar的数值
let prevSeriesValue = 0;
if (!useStack) {
return prevSeriesValue;
} else {
for (let i = seriesIndex - 1; i >= 0; i--) {
prevSeriesValue += dataset[catagroyIndex][i];
}
return prevSeriesValue;
}
}
/**
* 通过坐标判断是否满足bar的最小高度,不满足的话手动修改y坐标,上移HORIZONTAL_BAR_MIN_HEIGHT
*
* @param direction
* @param isRenderDataZero 是否渲染数据0
* @param seriesTopLocation
* @param seriesBottomLocation
* @returns
*/
export function handleSeriesMinHeight(
direction: "horizontal" | "column",
isRenderDataZero: boolean,
seriesTopLocation: number[],
seriesBottomLocation: number[]
) {
if (direction === "horizontal") {
if (seriesTopLocation[1] - seriesBottomLocation[1] < 0) {
// 正常情况下top的y坐标比botton的y坐标小
if (Math.abs(seriesTopLocation[1] - seriesBottomLocation[1]) < HORIZONTAL_BAR_MIN_HEIGHT) {
seriesTopLocation[1] = seriesBottomLocation[1] - HORIZONTAL_BAR_MIN_HEIGHT;
}
} else if (seriesTopLocation[1] - seriesBottomLocation[1] === 0) {
if (!isRenderDataZero) {
// 数据值为0,什么都不做。
} else {
//得先把top的y放到bottom的y上面
seriesTopLocation[1] =
seriesTopLocation[1] - (seriesTopLocation[1] - seriesBottomLocation[1]) * 2;
seriesTopLocation[1] = seriesBottomLocation[1] - HORIZONTAL_BAR_MIN_HEIGHT;
}
} else {
// 非正常情况(在handleSeriesImpact检测碰撞后,top的y坐标比botton的y坐标大)需要手动修改
//得先把top的y放到bottom的y上面
seriesTopLocation[1] =
seriesTopLocation[1] - (seriesTopLocation[1] - seriesBottomLocation[1]) * 2;
seriesTopLocation[1] = seriesBottomLocation[1] - HORIZONTAL_BAR_MIN_HEIGHT;
}
} else {
if (seriesTopLocation[0] - seriesBottomLocation[0] > 0) {
// 正常情况下top的y坐标比botton的y坐标小
if (Math.abs(seriesTopLocation[0] - seriesBottomLocation[0]) < VERTICAL_BAR_MIN_WIDTH) {
seriesTopLocation[0] = seriesBottomLocation[0] + VERTICAL_BAR_MIN_WIDTH;
}
} else if (seriesTopLocation[0] - seriesBottomLocation[0] === 0) {
if (!isRenderDataZero) {
// 数据值为0,什么都不做。
} else {
//得先把top的y放到bottom的y上面
seriesTopLocation[0] =
seriesTopLocation[0] - (seriesTopLocation[0] - seriesBottomLocation[0]) * 2;
seriesTopLocation[0] = seriesBottomLocation[0] + VERTICAL_BAR_MIN_WIDTH;
}
} else {
// 非正常情况(在handleSeriesImpact检测碰撞后,top的y坐标比botton的y坐标大)需要手动修改
//得先把top的y放到bottom的y上面
seriesTopLocation[0] =
seriesTopLocation[0] - (seriesTopLocation[0] - seriesBottomLocation[0]) * 2;
seriesTopLocation[0] = seriesBottomLocation[0] + VERTICAL_BAR_MIN_WIDTH;
}
}
return [seriesTopLocation, seriesBottomLocation];
}
/**
* 获取缓存坐标map每一项的key
*
* @param catagroyIndex
* @param seriesIndex
* @returns
*/
export function getCacheSeriesCoordMapKey(catagroyIndex: number, seriesIndex: number) {
return `${catagroyIndex}-${seriesIndex}`;
}
/**
* 缓存坐标
*
* @param seriesTopLocation
* @param seriesBottomLocation
* @param catagroyIndex
* @param seriesIndex
* @param seriesCoordMap
* @returns
*/
export function cacheSeriesCoord(
seriesTopLocation: number[],
seriesBottomLocation: number[],
catagroyIndex: number,
seriesIndex: number,
seriesCoordMap: Map<string, any> = SERIES_COORD_MAP
) {
const key = getCacheSeriesCoordMapKey(catagroyIndex, seriesIndex);
// 不能使用has判断,因为开启datazoom后鼠标移动画布也会导致重绘图,此时需要更新map中的坐标
seriesCoordMap.set(key, [seriesTopLocation, seriesBottomLocation]);
return seriesCoordMap;
}
/**
* 控制是否渲染数据0,返回true表示渲染,程序继续执行返回3dbar;返回false则程序中断,返回空即可不渲染数据0
*
* @param direction
* @param isRenderDataZero
* @param seriesTopLocation
* @param seriesBottomLocation
* @returns
*/
export function handleRenderZero(
direction: "horizontal" | "column",
isRenderDataZero: boolean,
seriesTopLocation: number[],
seriesBottomLocation: number[]
) {
if (direction === "horizontal") {
if (!isRenderDataZero) {
if (seriesTopLocation[1] - seriesBottomLocation[1] === 0) return false;
return true;
} else {
return true;
}
} else {
if (!isRenderDataZero) {
if (seriesTopLocation[0] - seriesBottomLocation[0] === 0) return false;
return true;
} else {
return true;
}
}
}
/**
* 使用当前series底点的y坐标与上一个series顶点的y坐标进行碰撞检测
*
* @param isRenderDataZero 是否渲染数据0
* @param direction
* @param useStack
* @param catagroyIndex
* @param seriesIndex
* @param seriesCoordMap
* @returns
*/
export function handleSeriesImpact(
isRenderDataZero: boolean,
direction: "horizontal" | "column",
useStack = true,
catagroyIndex: number,
seriesIndex: number,
seriesCoordMap: Map<string, any>
) {
const getTargetSeries = (
catagroyIndex: number,
seriesIndex: number,
seriesCoordMap: Map<string, any>
) => {
const key = getCacheSeriesCoordMapKey(catagroyIndex, seriesIndex);
return seriesCoordMap.get(key);
};
if (direction === "horizontal") {
let _seriesTopLocation: number[] = [];
let _seriesBottomLocation: number[] = [];
// 当前series坐标
const [seriesTopLocation, seriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex,
seriesCoordMap
);
if (!useStack) {
// 关闭stack后不继续执行
return [seriesTopLocation, seriesBottomLocation];
}
if (seriesIndex > 0) {
// 从第2个series开始,每次与上一个series进行碰撞检测
// 上一个series坐标
const [preSeriesTopLocation, preSeriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex - 1,
seriesCoordMap
);
// 使用当前series底点的y坐标与上一个series顶点的y坐标进行碰撞检测
// 让当前series的底点与上一个series的顶点贴合
seriesBottomLocation[1] = preSeriesTopLocation[1];
// 确保当前series的高度
const location = handleSeriesMinHeight(
direction,
isRenderDataZero,
seriesTopLocation,
seriesBottomLocation
);
_seriesTopLocation = location[0];
_seriesBottomLocation = location[1];
} else {
// 第1个series不用碰撞检测
_seriesTopLocation = seriesTopLocation;
_seriesBottomLocation = seriesBottomLocation;
}
return [_seriesTopLocation, _seriesBottomLocation];
} else {
// 水平方向
let _seriesTopLocation: number[] = [];
let _seriesBottomLocation: number[] = [];
// 当前series坐标
const [seriesTopLocation, seriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex,
seriesCoordMap
);
if (!useStack) {
// 关闭stack后不继续执行
return [seriesTopLocation, seriesBottomLocation];
}
if (seriesIndex > 0) {
// 从第2个series开始,每次与上一个series进行碰撞检测
// 上一个series坐标
const [preSeriesTopLocation, preSeriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex - 1,
seriesCoordMap
);
// 使用当前series底点的y坐标与上一个series顶点的y坐标进行碰撞检测
// 让当前series的底点与上一个series的顶点贴合
seriesBottomLocation[0] = preSeriesTopLocation[0];
// 确保当前series的高度
const location = handleSeriesMinHeight(
direction,
isRenderDataZero,
seriesTopLocation,
seriesBottomLocation
);
_seriesTopLocation = location[0];
_seriesBottomLocation = location[1];
} else {
// 第1个series不用碰撞检测
_seriesTopLocation = seriesTopLocation;
_seriesBottomLocation = seriesBottomLocation;
}
return [_seriesTopLocation, _seriesBottomLocation];
}
}
/**
* 处理每个系列之间的间隙,x/y轴变化,第一个不用处理
*
* @param direction
* @param useStack
* @param seriesIndex
* @param seriesTopLocation
* @param seriesBottomLocation
* @param offset
* @returns
*/
export function handleSeriesOffset(
direction: "horizontal" | "column",
useStack = true,
seriesIndex: number,
seriesTopLocation: number[],
seriesBottomLocation: number[],
offset?: number
) {
if (seriesIndex > 0) {
if (direction === "horizontal") {
const _offset = offset || HORIZONTAL_3DBAR_GAP;
if (!useStack) {
seriesTopLocation[0] = seriesTopLocation[0] + DEFAULT_NO_STACK_OFFSET * 2;
seriesBottomLocation[0] = seriesBottomLocation[0] + DEFAULT_NO_STACK_OFFSET * 2;
} else {
seriesTopLocation[1] = seriesTopLocation[1] + _offset;
seriesBottomLocation[1] = seriesBottomLocation[1] + _offset;
}
} else {
// 竖直
const _offset = offset || VERTICAL_3DBAR_GAP;
if (!useStack) {
seriesTopLocation[1] = seriesTopLocation[1] + DEFAULT_NO_STACK_OFFSET * 2;
seriesBottomLocation[1] = seriesBottomLocation[1] + DEFAULT_NO_STACK_OFFSET * 2;
} else {
seriesTopLocation[0] = seriesTopLocation[0] - _offset;
seriesBottomLocation[0] = seriesBottomLocation[0] - _offset;
}
}
}
return [seriesTopLocation, seriesBottomLocation];
}
/**
* 角度转弧度
*
* @param angle
*/
function transformAngleToRadian(angle: number) {
return angle * (Math.PI / 180);
}
/** 顶部旋转角度 */
const DEFAULT_BAR_TOP_ROTATION_ANGLE = 30;
/**
* 根据顶点坐标、边长、旋转角度来计算除顶点外其他三个点的坐标
*
* t => t' [x, y]
*
* l => l' [x + width * sin(angle), y + width * cos(angle)]
*
* b => b' [x + √2 * width * cos(90/2 + angle), y + √2 * width * sin(90/2 + angle)]
*
* r => r' [x + width * cos(angle), y + width * sin(angle)]
*
* 其中根号2直接取 1.414
*
* @param location
* @param width
* @param angle
*/
function calcBarTopLocation(location: number[], width: number, angle: number) {
const sqrt2 = Math.sqrt(2);
const [x, y] = location;
const t = [x, y];
const l = [
x - width * Math.sin(transformAngleToRadian(angle)),
y + width * Math.cos(transformAngleToRadian(angle))
];
const b = [
x + sqrt2 * width * Math.cos(transformAngleToRadian(45 + angle)),
y + sqrt2 * width * Math.sin(transformAngleToRadian(45 + angle))
];
const r = [
x + width * Math.cos(transformAngleToRadian(angle)),
y + width * Math.sin(transformAngleToRadian(angle))
];
return [t, l, b, r];
}
/**
* 计算比例
*
* @param direction
* @param api
* @returns
*/
function getScale(direction: "horizontal" | "column", api: any) {
if (direction === "horizontal") {
// 获取一个像素对应的坐标
const point = api.size([0, 1]);
const y = point[1];
// return y / 1; // 需要等比缩放再放开
return 1;
} else {
return 1;
}
}
function getScaledBarInfo(scale: number, width: number, height: number, offsetX: number) {
return {
width: width * scale,
height: height * scale,
offsetX: offsetX * scale
};
}
/**
* 使用canvas画3d的柱形
*
* 注意⚠️:
* 1. 所画图形还是2d,t/l/b/r 代表顶/左/底/右四个点(把这个面理解为棱形即可,顺时针)
*
* @param extendShape 自定义图形
* @param registerShape 向echarts实例注册自定义图形
*/
export function creator(extendShape, registerShape) {
// 绘制顶面
registerShape(
"3DBarTop",
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape) {
if (shape.direction === "horizontal") {
// 纯正方形
// const {
// seriesTopInfo = { width: HORIZONTAL_BAR_TOP_WIDTH, angle: DEFAULT_BAR_TOP_ROTATION_ANGLE },
// seriesTopLocation
// } = shape;
// const [t, l, b, r] = calcBarTopLocation(
// seriesTopLocation,
// seriesTopInfo.width,
// seriesTopInfo.angle
// );
// ctx.moveTo(t[0], t[1]).lineTo(l[0], l[1]).lineTo(b[0], b[1]).lineTo(r[0], r[1]).closePath();
// ui要求不规则正方形
const { seriesTopLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesTopLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
HORIZONTAL_BAR_TOP_WIDTH,
HORIZONTAL_BAR_TOP_HEIGHT,
HORIZONTAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - noStackOffsetX, y1];
l = [x1 - width / 2 - noStackOffsetX, y1 + height / 2];
b = [x1 + offsetX - noStackOffsetX, y1 + height];
r = [x1 + width / 2 + offsetX - noStackOffsetX, y1 + height / 2];
} else {
t = [x1, y1];
l = [x1 - width / 2, y1 + height / 2];
b = [x1 + offsetX, y1 + height];
r = [x1 + width / 2 + offsetX, y1 + height / 2];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
} else {
// 竖直方向
// ui要求不规则正方形
const { seriesTopLocation: seriesRightLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesRightLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
VERTICAL_BAR_TOP_WIDTH,
VERTICAL_BAR_TOP_HEIGHT,
VERTICAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
// const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width / 2, y1 - height / 2];
l = [x1 - width, y1 + offsetX];
b = [x1 - width / 2, y1 + height / 2 + offsetX];
r = [x1, y1];
} else {
t = [x1 - width / 2, y1 - height / 2];
l = [x1 - width, y1 + offsetX];
b = [x1 - width / 2, y1 + height / 2 + offsetX];
r = [x1, y1];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
}
})
);
// 绘制左侧面
registerShape(
"3DBarLeft",
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape) {
if (shape.direction === "horizontal") {
const { seriesTopLocation, seriesBottomLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
HORIZONTAL_BAR_TOP_WIDTH,
HORIZONTAL_BAR_TOP_HEIGHT,
HORIZONTAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width / 2 - noStackOffsetX, y1 + height / 2];
l = [x2 - width / 2 - noStackOffsetX, y2 - height / 2];
b = [x2 + offsetX - noStackOffsetX, y2];
r = [x1 + offsetX - noStackOffsetX, y1 + height];
} else {
t = [x1 - width / 2, y1 + height / 2];
l = [x2 - width / 2, y2 - height / 2];
b = [x2 + offsetX, y2];
r = [x1 + offsetX, y1 + height];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
} else {
// 竖直方向
const {
seriesTopLocation: seriesRightLocation,
seriesBottomLocation: seriesLeftLocation,
direction,
useStack,
api
} = shape;
const [x2, y2] = seriesLeftLocation;
const [x1, y1] = seriesRightLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
VERTICAL_BAR_TOP_WIDTH,
VERTICAL_BAR_TOP_HEIGHT,
VERTICAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
// const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width / 2, y1 - height / 2];
l = [x2 + width / 2, y1 - height / 2];
b = [x2, y1 + offsetX];
r = [x1 - width, y1 + offsetX];
} else {
t = [x1 - width / 2, y1 - height / 2];
l = [x2 + width / 2, y1 - height / 2];
b = [x2, y1 + offsetX];
r = [x1 - width, y1 + offsetX];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
}
})
);
// 绘制右侧面
registerShape(
"3DBarRight",
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape) {
if (shape.direction === "horizontal") {
const { seriesTopLocation, seriesBottomLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
HORIZONTAL_BAR_TOP_WIDTH,
HORIZONTAL_BAR_TOP_HEIGHT,
HORIZONTAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 + offsetX - noStackOffsetX, y1 + height];
l = [x2 + offsetX - noStackOffsetX, y2];
b = [x1 + width / 2 + offsetX - noStackOffsetX, y2 - height / 2];
r = [x1 + width / 2 + offsetX - noStackOffsetX, y1 + height / 2];
} else {
t = [x1 + offsetX, y1 + height];
l = [x2 + offsetX, y2];
b = [x1 + width / 2 + offsetX, y2 - height / 2];
r = [x1 + width / 2 + offsetX, y1 + height / 2];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
} else {
// 竖直方向
const {
seriesTopLocation: seriesRightLocation,
seriesBottomLocation: seriesLeftLocation,
direction,
useStack,
api
} = shape;
const [x2, y2] = seriesLeftLocation;
const [x1, y1] = seriesRightLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
VERTICAL_BAR_TOP_WIDTH,
VERTICAL_BAR_TOP_HEIGHT,
VERTICAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
// const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width, y1 + offsetX];
l = [x2, y1 + offsetX];
b = [x2 + width / 2, y1 + height / 2 + offsetX];
r = [x1 - width / 2, y1 + height / 2 + offsetX];
} else {
t = [x1 - width, y1 + offsetX];
l = [x2, y1 + offsetX];
b = [x2 + width / 2, y1 + height / 2 + offsetX];
r = [x1 - width / 2, y1 + height / 2 + offsetX];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
}
})
);
}