echarts3D立体环形图
效果图如下
components 中的echarts/index.tsx
import * as React from 'react';
import classnames from 'classnames';
import * as echarts from 'echarts';
import 'echarts-gl';
import _ from 'lodash';
import { useSize, useUnmount } from 'ahooks';
import Util from '@/common/util';
import style from './style.less';
import { getParametricEquation } from './util';
export type EChartsProps = {
option: echarts.EChartsOption;
opts?: {
devicePixelRatio?: number;
renderer?: 'canvas' | 'svg';
useDirtyRect?: boolean;
width?: number;
height?: number;
locale?: any;
};
theme?: string | object;
style?: React.CSSProperties;
className?: string;
getContainer?: () => HTMLElement | React.RefObject<HTMLElement>;
onClick?: (params: any) => void;
allowListen?: boolean;
loop?: boolean;
onmouseover?: () => () => void;
onmouseout?: () => () => void;
};
type EChartOptionProperty = echarts.EChartsOption[keyof echarts.EChartsOption];
const ECharts = React.forwardRef<echarts.ECharts | undefined, EChartsProps>(
(props, ref) => {
const {
style: $style,
className,
option,
theme,
opts,
getContainer,
onClick,
allowListen = false,
loop = false,
// onmouseover,
// onmouseout,
} = props;
const chartDOMRef = React.useRef<HTMLDivElement>(null);
const cachedOptionRef = React.useRef<echarts.EChartsOption>();
const chartInstance = React.useRef<echarts.ECharts>();
const tooltipRef = React.useRef<NodeJS.Timeout>();
const { width = 0, height = 0 } = useSize(
_.isFunction(getContainer)
? getContainer()
: () => chartDOMRef.current?.parentElement || null,
) || { width: 0, height: 0 };
const mergeDefaultProperty = React.useCallback(
(
property: EChartOptionProperty,
defaultProperty: EChartOptionProperty,
) => {
if (_.isArray(property)) {
return property.map((item) => {
if (_.isObject(item)) {
return _.merge({}, defaultProperty, item);
}
return item;
});
}
if (!Util.isEmpty(property) && _.isObject(property)) {
return _.merge({}, defaultProperty, property);
}
return property;
},
[],
);
const mergeDefaultOption = React.useCallback(
($option: echarts.EChartsOption) => {
const defaultOption: echarts.EChartsOption = {
animation: true,
animationDuration: 3000,
grid: {
left: 0,
right: 0,
top: 0,
bottom: 0,
containLabel: true,
},
};
const defaultProperty: echarts.EChartsOption = {
legend: {
itemWidth: 16,
itemHeight: 16,
},
series: {
barWidth: 14,
},
};
const mergeOption: echarts.EChartsOption = _.merge(
{},
defaultOption,
$option,
);
Object.keys($option).forEach((key) => {
if (key in defaultProperty) {
mergeOption[key] = mergeDefaultProperty(
$option[key],
defaultProperty[key],
);
} else {
mergeOption[key] = $option[key];
}
});
return mergeOption;
},
[mergeDefaultProperty],
);
const setOptionHandler = React.useCallback(
($option: echarts.EChartsOption) => {
if (
chartInstance.current &&
!chartInstance.current?.isDisposed() &&
!Util.isEmpty($option)
) {
const newOption = mergeDefaultOption($option);
// console.log(newOption, '0000');
// if (!_.isEqual(cachedOptionRef.current, newOption)) {
chartInstance.current.clear();
chartInstance.current.setOption(newOption);
cachedOptionRef.current = newOption;
// }
}
},
[mergeDefaultOption],
);
const onClickHandler = React.useCallback(
(params: any) => {
if (_.isFunction(onClick)) {
onClick(params);
}
},
[onClick],
);
// 监听鼠标事件,实现饼图选中效果(单选),近似实现高亮(放大)效果。
// $optionName是防止有多个图表进行定向option传递,单个图表可以不传,默认是opiton
const handleListen = React.useCallback(
(myChart: echarts.ECharts) => {
let hoveredIndex: number | undefined = 0;
const $option: any = { ...option };
// 监听 mouseover,近似实现高亮(放大)效果
// 监听 mouseover,近似实现高亮(放大)效果
myChart.on('mouseover', (params) => {
// 准备重新渲染扇形所需的参数
let isSelected;
let isHovered;
let startRatio;
let endRatio;
let k;
const { seriesIndex } = params;
// 如果触发 mouseover 的扇形当前已高亮,则不做操作
if (hoveredIndex === seriesIndex) {
// 否则进行高亮及必要的取消高亮操作
} else {
// 如果当前有高亮的扇形,取消其高亮状态(对 option 更新)
if (
(hoveredIndex || 0 === hoveredIndex) &&
_.isArray($option.series) &&
$option.series[hoveredIndex]
) {
const seriesItem = $option.series[hoveredIndex];
// 从 option.series 中读取重新渲染扇形所需的参数,将是否高亮设置为 false。
isSelected = false;
isHovered = false;
startRatio = seriesItem.pieData.startRatio;
endRatio = seriesItem.pieData.endRatio;
k = seriesItem.pieStatus.k;
const $height =
0 === hoveredIndex
? $option.series[hoveredIndex].pieData.height - 3
: $option.series[hoveredIndex].pieData.height;
// 对当前点击的扇形,执行取消高亮操作(对 option 更新)
$option.series[hoveredIndex].parametricEquation =
getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
$height,
);
$option.series[hoveredIndex].pieStatus.hovered = isHovered;
// 将此前记录的上次选中的扇形对应的系列号 seriesIndex 清空
hoveredIndex = undefined;
}
// 触发 mouseover 将其高亮(对 option 更新)
if (
_.isArray($option.series) &&
(seriesIndex || 0 === seriesIndex) &&
$option.series[seriesIndex]
) {
const seriesItem = $option.series[seriesIndex];
// 从 option.series 中读取重新渲染扇形所需的参数,将是否高亮设置为 true。
isSelected = false;
isHovered = true;
startRatio = seriesItem.pieData.startRatio;
endRatio = seriesItem.pieData.endRatio;
k = seriesItem.pieStatus.k;
const $height =
0 === seriesIndex
? seriesItem.pieData.height
: seriesItem.pieData.height + 3;
// console.log(seriesItem, 'seriesItem');
// 对当前点击的扇形,执行高亮操作(对 option 更新)
$option.series[seriesIndex].parametricEquation =
getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
$height,
);
$option.series[seriesIndex].pieStatus.hovered = isHovered;
$option.title.text = seriesItem.pieData.per;
$option.title.subtext = seriesItem.pieData.name;
// 记录上次高亮的扇形对应的系列号 seriesIndex
hoveredIndex = seriesIndex;
// 使用更新后的 option,渲染图表
chartInstance.current?.setOption({ ...$option });
}
}
});
// 修正取消高亮失败的 bug
myChart.on('globalout', () => {
// 准备重新渲染扇形所需的参数
let isSelected;
let isHovered;
let startRatio;
let endRatio;
let k;
$option.title.show = true;
if (hoveredIndex && _.isArray($option.series)) {
const seriesItem = $option.series[hoveredIndex];
// 从 $option.series 中读取重新渲染扇形所需的参数,将是否高亮设置为 true。
isSelected = false;
isHovered = false;
k = seriesItem.pieStatus.k;
startRatio = seriesItem.pieData.startRatio;
endRatio = seriesItem.pieData.endRatio;
// 对当前点击的扇形,执行取消高亮操作(对 $option 更新)
$option.series[hoveredIndex].parametricEquation =
getParametricEquation(
startRatio,
endRatio,
isSelected,
isHovered,
k,
$option.series[hoveredIndex].pieData.height,
);
$option.series[hoveredIndex].pieStatus.hovered = isHovered;
// 将此前记录的上次选中的扇形对应的系列号 seriesIndex 清空
hoveredIndex = 0;
$option.series[0].parametricEquation = getParametricEquation(
$option.series[0].pieData.startRatio,
$option.series[0].pieData.endRatio,
$option.series[0].pieData.isSelected,
$option.series[0].pieData.isHovered,
$option.series[0].pieData.k,
$option.series[0].pieData.height,
);
$option.title.text = $option.series[0].pieData.per;
$option.title.subtext = $option.series[0].pieData.name;
}
// 使用更新后的 option,渲染图表
chartInstance.current?.setOption({ ...$option });
});
},
[option],
);
// tooltip 轮播
const getTooltipLoop = React.useCallback(() => {
let currentIndex = -1;
tooltipRef.current = setInterval(() => {
let dataLen = 0;
if (_.isArray(option.series) && _.isArray(option.series[0].data)) {
dataLen = option.series[0].data.length;
} else if (_.isArray(option.series)) {
dataLen = option.series?.length;
}
// 取消之前高亮的图形
chartInstance.current?.dispatchAction({
type: 'downplay',
seriesIndex: 0, // 表示series中的第几个data数据循环展示
dataIndex: currentIndex,
});
currentIndex = (currentIndex + 1) % dataLen; // +1表示每次跳转一个
// 高亮当前图形
chartInstance.current?.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: currentIndex,
});
// 显示 tooltip
chartInstance.current?.dispatchAction({
type: 'showTip',
seriesIndex: 0,
dataIndex: currentIndex,
});
}, 3000);
}, [option.series]);
React.useEffect(() => {
if (chartDOMRef.current) {
chartInstance.current = echarts.init(chartDOMRef.current, theme, opts);
chartInstance.current.on('click', onClickHandler);
if (allowListen) {
handleListen(chartInstance.current);
}
if (tooltipRef.current) {
clearTimeout(tooltipRef.current);
}
if (loop) {
getTooltipLoop();
}
}
}, [
opts,
theme,
onClickHandler,
allowListen,
handleListen,
loop,
getTooltipLoop,
]);
React.useEffect(() => {
setOptionHandler(option);
}, [option, setOptionHandler]);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout>();
React.useEffect(() => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (!chartInstance.current?.isDisposed()) {
chartInstance.current?.resize();
}
}, 200);
}, [getContainer, width, height]);
useUnmount(() => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
if (chartInstance.current && !chartInstance.current?.isDisposed()) {
chartInstance.current.dispose();
}
if (tooltipRef.current) {
clearTimeout(tooltipRef.current);
}
});
React.useImperativeHandle(ref, () => chartInstance.current);
return (
<div
ref={chartDOMRef}
className={classnames(style.echarts, className)}
style={$style}
/>
);
},
);
export default ECharts;
components 中的echarts/util.ts
const getParametricEquation = (
startRatio: number,
endRatio: number,
isSelected: boolean,
isHovered: boolean,
k: number,
height: number,
) => {
// 计算
const midRatio = (startRatio + endRatio) / 2;
const startRadian = startRatio * Math.PI * 2;
const endRadian = endRatio * Math.PI * 2;
const midRadian = midRatio * Math.PI * 2;
// 如果只有一个扇形,则不实现选中效果。
if (0 === startRatio && 1 === endRatio) {
isSelected = false;
}
// 通过扇形内径/外径的值,换算出辅助参数 k(默认值 1/3)
k = k || 1 / 5;
// 计算选中效果分别在 x 轴、y 轴方向上的位移(未选中,则位移均为 0)
const offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;
const offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0;
// 计算高亮效果的放大比例(未高亮,则比例为 1)
const hoverRate = isHovered ? 1 : 1;
// 返回曲面参数方程
return {
u: {
min: -Math.PI,
max: Math.PI * 3,
step: Math.PI / 32,
},
v: {
min: 0,
max: Math.PI * 2,
step: Math.PI / 20,
},
x: (u: number, v: number) => {
if (u < startRadian) {
return (
offsetX + Math.cos(startRadian) * (1 + Math.cos(v) * k) * hoverRate
);
}
if (u > endRadian) {
return (
offsetX + Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate
);
}
return offsetX + Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate;
},
y: (u: number, v: number) => {
if (u < startRadian) {
return (
offsetY + Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate
);
}
if (u > endRadian) {
return (
offsetY + Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate
);
}
return offsetY + Math.sin(u) * (1 + Math.cos(v) * k) * hoverRate;
},
z: (u: number, v: number) => {
if (u < -Math.PI * 0.5) {
return Math.sin(u);
}
if (u > Math.PI * 2.5) {
return Math.sin(u);
}
return Math.sin(v) > 0 ? 0.5 * height : -1;
},
};
};
export { getParametricEquation };
组件3D-pie
import React from 'react';
import { ECharts } from '@/components';
import { getParametricEquation } from '@/components/echarts/util';
import { context } from '../../../controller';
import style from './style.less';
import BG from '../../../images/pie-bg2.png';
const source = [
{
name: '男',
value: 4,
height: 4,
per: '40%',
itemStyle: {
color: '#66E1DF',
},
},
{
name: '男2',
value: 2,
height: 2,
per: '20%',
itemStyle: {
color: '#6A14FF',
},
},
{
name: '女',
value: 2,
height: 2,
per: '20%',
itemStyle: {
color: '#0D97FF',
},
},
{
name: '女2',
value: 2,
height: 2,
per: '20%',
itemStyle: {
color: '#34FFBF',
},
},
];
const Page = () => {
const { data } = React.useContext(context);
const { tradeAmountGroupDtoList: list } = data;
const $list = list
? list?.map((item, index) => ({
height: source[index].height,
name: item.groupName,
per: `${(item.proportion || 0) * 100}%`,
value: Number(item.tradeCount),
itemStyle: source[index].itemStyle,
}))
: [];
console.log($list);
const getPie3D = React.useCallback(
(pieData: any[], internalDiameterRatio: number) => {
const series: any[] = [];
let sumValue = 0;
let startValue = 0;
let endValue = 0;
const k = internalDiameterRatio || 1 / 5;
// 为每一个饼图数据,生成一个 series-surface 配置
for (let i = 0; i < pieData.length; i++) {
sumValue += pieData[i].value;
const seriesItem = {
name:
'undefined' === typeof pieData[i].name
? `series${i}`
: pieData[i].name,
type: 'surface',
parametric: true,
wireframe: {
show: false,
},
pieData: pieData[i],
pieStatus: {
selected: false,
hovered: false,
k,
},
itemStyle: {},
};
if (pieData[i].itemStyle) {
const itemStyle: any = {};
if (pieData[i].itemStyle.color) {
itemStyle.color = pieData[i].itemStyle.color;
}
if (pieData[i].itemStyle.opacity) {
itemStyle.opacity = pieData[i].itemStyle.opacity;
}
seriesItem.itemStyle = itemStyle;
}
series.push(seriesItem);
}
// 使用上一次遍历时,计算出的数据和 sumValue,调用 getParametricEquation 函数,
// 向每个 series-surface 传入不同的参数方程 series-surface.parametricEquation,也就是实现每一个扇形。
for (let i = 0; i < series.length; i++) {
endValue = startValue + series[i].pieData.value;
series[i].pieData.startRatio = startValue / sumValue;
series[i].pieData.endRatio = endValue / sumValue;
series[i].parametricEquation = getParametricEquation(
series[i].pieData.startRatio,
series[i].pieData.endRatio,
series[i].pieStatus.selected,
series[i].pieStatus.hovered,
k,
series[i].pieData.height,
);
startValue = endValue;
}
const $option = {
tooltip: {
show: false,
},
title: {
show: true,
text: series[0]?.pieData?.per || '0%',
subtext: series[0]?.pieData?.name || '',
textStyle: {
color: '#fff',
fontFamily: 'FCBTT',
fontSize: '70px',
},
subtextStyle: {
fontSize: '30px',
color: '#fff',
},
left: 'center',
top: '15%',
},
legend: {
bottom: 40,
itemGap: 20,
textStyle: {
color: '#fff',
fontSize: 20,
},
},
xAxis3D: {
min: -1,
max: 1,
},
yAxis3D: {
min: -1,
max: 1,
},
zAxis3D: {
min: -1,
max: 1,
},
grid3D: {
show: false,
bottom: '10%',
boxHeight: 10,
boxDepth: 100,
environment: 'auto', // 背景色,auto为自适应颜色
viewControl: {
distance: 170,
alpha: 12,
beta: 45,
rotateSensitivity: 0, // 禁止拖动旋转
zoomSensitivity: 0, // 禁止缩放
},
},
series,
};
return $option;
},
[],
);
return (
<div className={style.charts_wrap}>
<img src={BG} alt="" className={style.charts_bg} />
<ECharts option={getPie3D(source, 0.2)} allowListen />
div>
);
};
export default Page;