废话不多说,直接上UI的第一版设计图稿
经过将近一个星期的爆肝,总有有了以下的实现:
然后经过2个星期的市场验证,产品那边反馈需要重新设计,重新实现,原因是不够直观。好吧,咱就是一个干活的命,产品经理怎么反馈,UI怎么设计,咱程序员直接躺平接受就好(此刻心里一万匹草泥马奔腾而过)。
然而事情都有二面,虽说这一版白肝了,咱还是经过研究学习到东西了,也就这点安慰了吧,这里记录一下,供后续参考,如果后面有类似的设计,咱就可以当CP工程师,喝茶摸鱼咯。
首先我肯定不是从0开始造轮子,我先找到了react-d3-tree 在此基础上改写它的源代码。可是怎么改写源代码方便呢,这里有个技巧就是我直接把源代码下载到一个根目录下面的vendor目录:
然后在package.json 里面增加"react-d3-tree": "file:vendors/react-d3-tree"
注意:如果修改了react-d3-tree
的源代码,需要增加版本号,然后运行dist类似的命令后(类似于库发布npm registry上),然后重新运行: npm install react-d3-tree
或者 yarn add react-d3-tree
, 源代码的改动才可以在依赖库中得以体现。
原始的库中并没有设计当中这种折现的实现,只给了4个选项:diagonal, elbow, step, straight, 其中elbow是一个曲线,但还是和UI给的设计有区别,所以这里是需要自己来实现这个pathFunc的:
const drawPath = (boxHeight, nodeSize) => (linkData) => {
const { source: { x: x1, y: y1 }, target: { x: x2, y: y2 } } = linkData;
const path = d3Path();
path.moveTo(x1, y1);
// 如果是单一子节点
if (x1 === x2) {
path.lineTo(x2, y2);
return path.toString();
}
const medianWidth = (nodeSize.y - boxHeight) / 2;
path.lineTo(x1, y1 + boxHeight);
if (Math.abs(x2 - x1) <= 2 * medianWidth) {
return linkVertical()({
source: [x1, y1 + boxHeight],
target: [x2, y2],
});
}
path.quadraticCurveTo(x1, y1 + boxHeight + medianWidth, x1 > x2 ? x1 - medianWidth : x1 + medianWidth, y1 + boxHeight + medianWidth);
path.lineTo(x1 > x2 ? x2 + medianWidth : x2 - medianWidth, y1 + boxHeight + medianWidth);
path.quadraticCurveTo(x2, y1 + boxHeight + medianWidth, x2, y2);
return path.toString();
};
具体的思路是节点2端是一个1/4 圆然后加上一个横线连接2个 1/4圆就行了
第二版设计图稿如图:
这个设计是中间2个立体的饼图作为中心,外部仪表盘围绕着中心,由一条虚线牵引。整个界面可以放大缩小,每个节点都可以通过拖拽改变位置。总的来看还是比较复杂,但是如果把问题拆解就比较简单,首先需要将 中心点和 仪表盘各自实现,这个比较简单,通过echarts就可以做到: 仪表盘的代码:
import React, { memo } from 'react';
import { EchartsReact } from '@/infra/echarts';
import { useGetEchartsOptions } from '@/infra/hooks';
import echarts from '@/infra/echarts/init';
import { colors } from '@/constants';
import style from './index.module.less';
const getCommonRestConfig = (factorValue, min, max) => ({
axisLabel: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
itemStyle: {},
detail: {
show: false,
},
title: {
show: false,
},
data: [{
name: '',
value: factorValue,
}],
pointer: {
show: false,
},
max,
min,
});
const guageOptions = (fontSize, factorValue = 5, min = 0, max = 10) => ({
colors,
series: [
{
// 背景
name: 'bg',
z: -15,
type: 'pie',
hoverAnimation: false,
legendHoverLink: false,
animation: false,
center: ['50%', '50%'],
radius: '100%',
startAngle: 0,
label: {
normal: {
show: false,
position: 'center',
},
emphasis: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
data: [
{
value: 100,
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(180, 209, 228, 1)',
},
{
offset: 1,
color: 'rgba(255, 255, 255, 1)',
},
]),
},
},
},
],
},
{
name: 'outterBottomBorder',
type: 'gauge',
radius: '100%',
startAngle: -52,
endAngle: 232,
...getCommonRestConfig(factorValue, min, max),
axisLine: {
roundCap: true,
lineStyle: {
color: [
[
1,
new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(54, 96, 155, 0.21)',
},
{
offset: 1,
color: 'rgba(119, 212, 211, 0.19)',
},
]),
],
],
width: fontSize(5),
},
},
},
{
// 外边
name: 'outterBorder',
type: 'gauge',
radius: '100%',
...getCommonRestConfig(factorValue, min, max),
axisLine: {
roundCap: true,
lineStyle: {
color: [
[
1,
new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#DAEAFE',
},
{
offset: 1,
color: '#8FA8CA',
},
]),
],
],
width: fontSize(10),
},
},
},
{
// 高亮线
name: 'highlightLine',
type: 'gauge',
radius: '92%',
...getCommonRestConfig(factorValue, min, max),
axisLine: {
lineStyle: {
color: [[1, '#fff']],
width: 1,
},
},
},
{
// 背景
type: 'pie',
z: -14,
hoverAnimation: false,
legendHoverLink: false,
animation: false,
center: ['50%', '50%'],
radius: '35%',
silence: true,
startAngle: 0,
label: {
normal: {
show: false,
position: 'center',
},
emphasis: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
data: [
{
value: 100,
itemStyle: {
opacity: 1,
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(181, 218, 222, 1)',
},
{
offset: 1,
color: 'rgba(255, 255, 255, 1)',
},
]),
},
},
},
],
},
{
// 内边
name: 'innerBorder',
type: 'gauge',
radius: '85%',
...getCommonRestConfig(factorValue, min, max),
axisLine: {
lineStyle: {
color: [[1, new echarts.graphic.LinearGradient(1, 1, 0, 0, [
{
offset: 0,
color: 'rgba(79, 206, 228, 1)',
},
{
offset: 1,
color: 'rgba(102, 144, 202, 1)',
},
])]],
width: 1,
shadowColor: 'rgba(145,207,255,.5)',
shadowBlur: 6,
shadowOffsetX: 0,
},
},
axisTick: {
show: true,
distance: fontSize(25),
splitNumber: 2,
lineStyle: {
color: 'rgba(130, 165, 206,0.25)',
width: fontSize(3),
},
},
axisLabel: {
show: true,
distance: -12,
fontSize: fontSize(10),
},
pointer: {
show: true,
length: '75%',
itemStyle: {
color: 'rgba(95, 122, 140, 1)',
},
},
},
{
// 背景
type: 'pie',
hoverAnimation: false,
legendHoverLink: false,
animation: false,
center: ['50%', '50%'],
radius: '20%',
silence: true,
startAngle: 0,
label: {
normal: {
show: false,
position: 'center',
},
emphasis: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
data: [
{
value: 100,
itemStyle: {
borderWidth: 1,
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(255, 255, 255, 1)',
},
{
offset: 1,
color: 'rgba(119, 212, 211, 0)',
},
]),
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(210, 251, 255, 0.8)',
},
{
offset: 1,
color: 'rgba(255, 255, 255, 0.8)',
},
]),
},
},
],
},
{
// 背景
type: 'pie',
hoverAnimation: false,
legendHoverLink: false,
animation: false,
center: ['50%', '50%'],
radius: '15%',
silence: true,
startAngle: 0,
label: {
normal: {
show: false,
position: 'center',
},
emphasis: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
data: [
{
value: 100,
itemStyle: {
borderWidth: 1,
borderColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(255, 255, 255, 1)',
},
{
offset: 1,
color: 'rgba(119, 212, 211, 0)',
},
]),
color: 'transparent',
},
},
],
},
],
});
export default memo(() => {
const chartOptions = useGetEchartsOptions(guageOptions);
return (
<EchartsReact className={style.GuageChart} option={chartOptions} />
);
});
立体双饼图
import React, { memo } from 'react';
import { EchartsReact } from '@/infra/echarts';
import { getHeight3D, getPie3DSeries } from '@/infra/echarts/three-dimensional-utils';
import { useGetEchartsOptions } from '@/infra/hooks';
// import pie3dImg from '@/styles/assets/img/pie3d.png';
import style from './index.module.less';
const data = [{
name: '数据1',
value: 21,
itemStyle: {
opacity: 0.7,
color: 'rgba(207, 121, 26, 1)',
},
}, {
name: '数据2',
value: 80,
itemStyle: {
color: 'rgba(79, 129, 195, 1)',
opacity: 0.7,
},
}];
const data1 = [{
name: '数据1',
value: 21,
itemStyle: {
opacity: 0.7,
color: 'rgba(207, 121, 26, 1)',
},
}, {
name: '数据2',
value: 80,
itemStyle: {
color: 'rgba(79, 129, 195, 1)',
opacity: 0.7,
},
}];
const pieOptions = (fontSize, pieData1, pieData2, boxHeight) => ({
// graphic: [
// {
// type: 'group',
// left: 'center',
// top: 'middle',
// children: [
// {
// type: 'image',
// style: {
// image: pie3dImg,
// },
// },
// ],
// },
// ],
xAxis3D: [{
min: -1,
max: 1,
grid3DIndex: 0,
}, {
min: -1,
max: 1,
grid3DIndex: 1,
}],
yAxis3D: [{
min: -1,
max: 1,
grid3DIndex: 0,
}, {
min: -1,
max: 1,
grid3DIndex: 1,
}],
zAxis3D: [{
min: -1,
max: 1,
grid3DIndex: 0,
}, {
min: -1,
max: 1,
grid3DIndex: 1,
}],
grid3D: [{
width: '100%',
height: '50%',
top: '-10%',
bottom: 0,
left: 0,
right: 0,
show: false,
boxHeight,
viewControl: {
autoRotate: false,
distance: 120,
alpha: 20,
zoomSensitivity: 0,
panSensitivity: 0,
rotateSensitivity: [0, 0],
},
}, {
width: '100%',
height: '50%',
show: false,
boxHeight,
top: '40%',
bottom: 0,
left: 0,
right: 0,
viewControl: {
autoRotate: false,
distance: 120,
alpha: 20,
zoomSensitivity: 0,
panSensitivity: 0,
rotateSensitivity: [0, 0],
},
}],
series: [...getPie3DSeries(pieData1, 0), ...getPie3DSeries(pieData2, 1)],
});
export default memo(() => {
const boxHeight = getHeight3D(data);
const chartOptions = useGetEchartsOptions(pieOptions, data, data1, boxHeight);
return (
<EchartsReact className={style.GuageChart} option={chartOptions} />
);
});
相当于我把节点级别的UI都实现了,那么现在就可以显示拖拽,放大缩小的功能了,这里我用了d3-zoom, d3-drag 这2个,毕竟我比较懒,不喜欢造轮子,直接运用就行了。代码也比较简单
import React, {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
select,
} from 'd3-selection';
import { zoom } from 'd3-zoom';
import { drag } from 'd3-drag';
import classNames from 'classnames';
import { useSize } from '@/infra/hooks';
import style from './index.module.less';
import Guage from '../factor-guage';
import Pie3D from '../3d/pie';
export default () => {
const primarySize = 200;
const factorSize = 150;
const [primaryOriginPointer, setPrimaryOriginPointer] = useState([400, 100]);
const [factorOriginPointer1, setFactorOriginPointer1] = useState([100, 0]);
const [factorOriginPointer2, setFactorOriginPointer2] = useState([100, 200]);
const primaryCenterPointer = useMemo(() => [primaryOriginPointer[0] + primarySize / 2, primaryOriginPointer[1] + primarySize / 2], [primaryOriginPointer, primarySize]);
const factorCenterPointer1 = useMemo(() => [factorOriginPointer1[0] + factorSize / 2, factorOriginPointer1[1] + factorSize / 2], [factorOriginPointer1, factorSize]);
const factorCenterPointer2 = useMemo(() => [factorOriginPointer2[0] + factorSize / 2, factorOriginPointer2[1] + factorSize / 2], [factorOriginPointer2, factorSize]);
// zoom function ref
const zoomFuncRef = useRef();
// drag function ref
const dragFuncRef = useRef();
const grabRef = useRef();
const grabSvgRef = useRef();
const [rootRef, rootSize] = useSize();
const viewBox = useMemo(() => [0, 0, rootSize?.width ?? 0, rootSize?.height ?? 0], [rootSize]);
const scaleRef = useRef(1);
useEffect(() => {
const zoomListener = ({ transform }) => {
const { x, y, k } = transform || {};
scaleRef.current = k;
select(grabRef.current).attr('style', `transform: translate(${x}px, ${y}px) scale(${k})`);
select(grabSvgRef.current).attr('transform', transform);
};
let offsetX = 0;
let offsetY = 0;
const dragstarted = function (event) {
// select(this).raise();
offsetX = event.sourceEvent.offsetX;
offsetY = event.sourceEvent.offsetY;
select(grabRef.current).attr('cursor', 'grabbing');
};
const dragged = function (event) {
if (select(this).classed('primary')) {
setPrimaryOriginPointer([event.x / scaleRef.current - offsetX, event.y / scaleRef.current - offsetY]);
}
if (select(this).classed('item1')) {
setFactorOriginPointer1([event.x / scaleRef.current - offsetX, event.y / scaleRef.current - offsetY]);
}
if (select(this).classed('item2')) {
setFactorOriginPointer2([event.x / scaleRef.current - offsetX, event.y / scaleRef.current - offsetY]);
}
select(this).attr('style', `transform: translate(${(event.x / scaleRef.current - offsetX)}px, ${(event.y / scaleRef.current - offsetY)}px)`);
};
const dragended = function () {
offsetY = 0;
offsetX = 0;
select(grabRef.current).attr('cursor', 'grab');
};
zoomFuncRef.current = zoom().scaleExtent([0.5, 2]);
zoomFuncRef.current.on('zoom', zoomListener);
select(rootRef.current).call(zoomFuncRef.current);
dragFuncRef.current = drag();
dragFuncRef.current
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}, [rootRef]);
useEffect(() => {
if (zoomFuncRef.current && rootSize) {
zoomFuncRef.current.extent([[0, 0], [rootSize.width, rootSize.height]]);
}
}, [rootSize]);
useEffect(() => {
select(grabRef.current).selectAll('.draggable').call(dragFuncRef.current);
}, []);
return (
<div ref={rootRef} className={classNames('wrapper', style.Root)}>
<svg viewBox={viewBox}>
<g ref={grabSvgRef}>
<line x1={factorCenterPointer1[0]} y1={factorCenterPointer1[1]} x2={primaryCenterPointer[0]} y2={primaryCenterPointer[1]} style={{ stroke: 'rgba(152, 186, 214, 1)', strokeWidth: 1, strokeDasharray: '4 1' }} />
<line x1={factorCenterPointer2[0]} y1={factorCenterPointer2[1]} x2={primaryCenterPointer[0]} y2={primaryCenterPointer[1]} style={{ stroke: 'rgba(152, 186, 214, 1)', strokeWidth: 1, strokeDasharray: '4 1' }} />
</g>
</svg>
<div className={classNames('grab', style.Canvas)} ref={grabRef}>
<div className={classNames('draggable', 'item1', style.CanvasItem)} style={{ transform: `translate(${factorOriginPointer1[0]}px, ${factorOriginPointer1[1]}px)` }}>
<div style={{ width: factorSize, height: factorSize }}>
<Guage />
</div>
</div>
<div className={classNames('draggable', 'item2', style.CanvasItem)} style={{ transform: `translate(${factorOriginPointer2[0]}px, ${factorOriginPointer2[1]}px)` }}>
<div style={{ width: factorSize, height: factorSize }}>
<Guage />
</div>
</div>
<div className={classNames('draggable', 'primary', style.CanvasItem)} style={{ transform: `translate(${primaryOriginPointer[0]}px, ${primaryOriginPointer[1]}px)` }}>
<div style={{ width: primarySize, height: primarySize }}>
<Pie3D />
</div>
</div>
</div>
</div>
);
};
至此一个POC的版本就完成了,当然后续还需要打磨,将数据变为动态数据。 效果图如下