记我的一次UI还原过程

1,934 阅读6分钟

废话不多说,直接上UI的第一版设计图稿

WechatIMG19.png

经过将近一个星期的爆肝,总有有了以下的实现:

WechatIMG20.png

然后经过2个星期的市场验证,产品那边反馈需要重新设计,重新实现,原因是不够直观。好吧,咱就是一个干活的命,产品经理怎么反馈,UI怎么设计,咱程序员直接躺平接受就好(此刻心里一万匹草泥马奔腾而过)。

然而事情都有二面,虽说这一版白肝了,咱还是经过研究学习到东西了,也就这点安慰了吧,这里记录一下,供后续参考,如果后面有类似的设计,咱就可以当CP工程师,喝茶摸鱼咯。

首先我肯定不是从0开始造轮子,我先找到了react-d3-tree 在此基础上改写它的源代码。可是怎么改写源代码方便呢,这里有个技巧就是我直接把源代码下载到一个根目录下面的vendor目录:

WechatIMG21.png

然后在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圆就行了

第二版设计图稿如图:

WechatIMG22.png

这个设计是中间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的版本就完成了,当然后续还需要打磨,将数据变为动态数据。 效果图如下

chrome-capture-2022-6-12.gif