使用 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
在业务过程中为了方便,我们不论是开发水平图表还是垂直图表都使用一套数据源,只更改xAxisyAxis 的配置即可。我们可以通过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 的基本使用很熟悉,这里需要快速查阅未使用过的 registerShapeextendShapecustom 等。
  1. 为了方便扩展与维护,可以把自定义图形绘制的逻辑与业务代码拆开。因为图形绘制只与传入的坐标和样式有关。
  1. 提取静态变量、动态变量
  1. 删除无用逻辑
// 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: {/* ... */}
				}
			]
		}
	}
}
// ...

用函数式编写堆叠逻辑

主要思路:
  1. 如何实现堆叠?
    1. 修改数据实现
    2. 修改坐标实现
  1. 如何保证渲染出来一个完整的柱状图?
    1. 修改数据实现
    2. 修改坐标实现
  1. 缓存坐标值
  1. 数据为0是否渲染?
  1. 堆叠的柱状图如何实现碰撞检测?
  1. 如何实现堆叠间隙?
  1. 如何控制不同方向的渲染
详情如下:
/* :========================================:: 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();
        }
      }
    })
  );
}
 

© i7eo 2017 - 2024