likes
comments
collection
share

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

作者站长头像
站长
· 阅读数 63

前言

今天分享一种非常酷炫的数据可视化方式——使用SVG创建简洁实用的柱形图和折线图!SVG是一种使用XML标记语言描述矢量图像的技术。与传统的栅格图像不同,SVG图像可以无损缩放,保持清晰度和细节,而且还可以添加各种交互效果和动画。这意味着我们可以通过SVG来快速、简单地创建出好看的数据可视化图表。

展示

先看完整的效果展示,也可以前往 zeng-j.github.io/react-svg-c… 查看。

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

柱形图

在数据可视化中,柱形图是一种常用而又强大的工具。它可以帮助我们直观地比较不同类别的数据,并从中提取关键信息。在本文中,将使用SVG实现轻量简洁的柱形图。

我们先约定下图表配置属性

属性说明
width图表宽度640
height图表高度480
labelFontSize标签字体大小12
yLabelWidthY 轴标签宽度36
yLabelPaddingRightY 轴标签右边距8
xLabelPaddingTopX 轴标签上边距8
yMaxValueY 轴最大值100
yTickCountY 轴刻度数量5
barWidth柱子宽度20

基于约定的属性,我们还要计算5个属性

  const coordinateLeftTopX = yLabelWidth;
  const coordinateLeftTopY = labelFontSize / 2;
  const verticalAxisHeight = height - coordinateLeftTopY - labelFontSize - xLabelPaddingTop;
  const horizontalAxisWidth = width - coordinateLeftTopX;
  const yGap = verticalAxisHeight / yTickCount;

对应的是

属性说明
coordinateLeftTopX坐标系左上角点的x坐标
coordinateLeftTopY坐标系左上角点的y坐标
horizontalAxisWidth坐标系的宽度
verticalAxisHeight坐标系的高度
yGapy 轴刻度线的间距

图标注如下,我们可以耐心看完,嫌麻烦得也可以继续往下。

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

y轴

现在开始我们就可以着手写代码啦。ts类型就不讲解了,可忽略。我们首先画y轴的坐标轴线,把坐标轴文本也补上。

function HistogramChart({ config }: HistogramChartProps) {
  const {
    width,
    height,
    yMaxValue,
    yLabelWidth,
    barWidth,
    yTickCount,
    labelFontSize,
    yLabelPaddingRight,
    xLabelPaddingTop,
  } = config;

  const coordinateLeftTopX = yLabelWidth;
  const coordinateLeftTopY = labelFontSize / 2;
  const verticalAxisHeight = height - coordinateLeftTopY - labelFontSize - xLabelPaddingTop;
  const horizontalAxisWidth = width - coordinateLeftTopX;
  const yGap = verticalAxisHeight / yTickCount;

  // y轴坐标系
  const yCoordinateAxisNode = useMemo(() => {
    // 刻度线单位值
    const yUnit = yMaxValue / yTickCount;
    // y轴刻度线
    const yLineList = Array.from({ length: yTickCount + 1 }).map((_, i) => yMaxValue - yUnit * i);
    return (
      <g>
        {yLineList.map((val, index) => {
          const yAxis = index * yGap + coordinateLeftTopY;
          return (
            <g key={val}>
              <text
                x={yLabelWidth - yLabelPaddingRight}
                y={yAxis}
                fill="#828B94"
                fontSize={labelFontSize}
                dominantBaseline="central"
                style={{ textAnchor: 'end' }}
              >
                {val}
              </text>
              <line
                x1={yLabelWidth}
                y1={yAxis}
                x2={width}
                y2={yAxis}
                stroke="#E1E8F7"
                strokeWidth="1"
                // x轴线为实线其他为虚线
                strokeDasharray={index !== yTickCount ? '4, 4' : undefined}
              />
            </g>
          );
        })}
      </g>
    );
  }, [
    coordinateLeftTopY,
    labelFontSize,
    width,
    yTickCount,
    yGap,
    yLabelPaddingRight,
    yLabelWidth,
    yMaxValue,
  ]);

  return (
    <svg width={width} height={height}>
      {/* y轴 */}
      {yCoordinateAxisNode}
    </svg>
  );
}

显示如下

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

x轴与柱形

紧接着我们来处理x轴,这时我们需要定义我们的数据源才能进一步处理。 我们约定数据源格式为,下面举例中我们需要显示2021和2022年的参与人数的柱形图

const data = [
  { labe: '2021', value: { name: '参与人数', value: 10 } },
  { labe: '2022', value: { name: '参与人数', value: 24 } }
]

处理前,我们可能容易忘记的一点,有必要提一下,svg的坐标系是从左上角为起始点的。

下面我们根据数据源来计算下x轴的刻度点,和每个柱形的位置和高度

function generateChartData(
  data: DataListItem[],
  {
    horizontalAxisWidth,
    yMaxValue,
    verticalAxisHeight,
    yLabelWidth,
    barWidth,
    coordinateLeftTopY,
  }: HistogramGenerateDataConfigType,
): HistogramChartDataListItem[] {
  // 平分横向坐标宽度
  const averageWidth = horizontalAxisWidth / data.length;

  return data.map((item, index) => {
    // x坐标刻度点
    const tickPosition = averageWidth * (index + 0.5) + yLabelWidth;
    const barHeight = (item.value.value / yMaxValue) * verticalAxisHeight;
    return {
      tickPosition,
      label: item.label,
      barData: {
        ...item.value,
        barHeight,
        // xPosition、yPosition是柱形左上角的坐标点
        xPosition: tickPosition - barWidth / 2,
        yPosition: coordinateLeftTopY + verticalAxisHeight - barHeight,
      },
    };
  });
}

我们结合图示例,来解释计算值代表的意思

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

现在开始画x轴的刻度线与坐标文本

function HistogramChart({ data, config }: HistogramChartProps) {
  // ...
  const chatData = useMemo(
    () =>
      generateChartData(data, {
        horizontalAxisWidth,
        yMaxValue,
        verticalAxisHeight,
        yLabelWidth,
        barWidth,
        coordinateLeftTopY,
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      // eslint-disable-next-line react-hooks/exhaustive-deps
      JSON.stringify(data),
      horizontalAxisWidth,
      yMaxValue,
      verticalAxisHeight,
      yLabelWidth,
      barWidth,
    ],
  );

  // x轴坐标系
  const xCoordinateAxisNode = useMemo(() => {
    return (
      <g>
        {chatData.map((item) => (
          <g key={item.tickPosition}>
            {/* x坐标轴刻度线 */}
            <line
              x1={item.tickPosition}
              x2={item.tickPosition}
              y1={coordinateLeftTopY + verticalAxisHeight}
              y2={coordinateLeftTopY + verticalAxisHeight + 6}
              stroke="#E1E8F7"
              strokeWidth="1"
            />
            {/* x坐标轴文本 */}
            <text
              x={item.tickPosition}
              y={height}
              dominantBaseline="text-after-edge"
              fontSize={labelFontSize}
              fill="#828b94"
              style={{ textAnchor: 'middle' }}
            >
              {item.label}
            </text>
          </g>
        ))}
      </g>
    );
  }, [chatData, coordinateLeftTopY, verticalAxisHeight, height, labelFontSize]);

  return (
    <svg width={width} height={height}>
      {/* y轴 */}
      {yCoordinateAxisNode}
      {/* x轴 */}
      {xCoordinateAxisNode}
    </svg>
  );
}

现在我们的坐标系就完成了

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

下面主要的一步,就是绘制柱形,但前面处理好数据了,绘制也是比较简单。

function HistogramChart({ data, config }: HistogramChartProps) {
  // ...
  
  // 柱形
  const barNode = useMemo(() => {
    return (
      <g>
        {chatData.map((item) => (
          <g key={item.label}>
            <rect
              key={`${item.label}_${item.barData.name}`}
              rx="2"
              x={item.barData.xPosition}
              y={item.barData.yPosition}
              height={item.barData.barHeight}
              width={barWidth}
              fill="#14DE95"
            />
          </g>
        ))}
      </g>
    );
  }, [chatData, barWidth]);

  return (
    <svg width={width} height={height}>
      {/* y轴 */}
      {yCoordinateAxisNode}
      {/* x轴 */}
      {xCoordinateAxisNode}
      {/* 柱形 */}
      {barNode}
    </svg>
  );
}

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

铛铛铛,基本的柱形图就完成啦。

hover柱形背景色

下面我们给图表加一下交互,当我们鼠标在图表上移动时,给柱形增加hover背景色。

我们给元素添加onMouseMove事件前,我们先了解下鼠标在svg标签的什么位置才需要交互,如下图,要排除坐标轴的文本区域。

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

所以我们先写个isWithinOrNot函数用来判断是否在坐标系内

// 判断鼠标是否在坐标系内
const isWithinOrNot = (e: MouseEvent) => {
  const rect = e.currentTarget?.getBoundingClientRect();
  const { clientX, clientY } = e;
  const x = clientX - rect.left;
  const y = clientY - rect.top;
  return {
    x,
    y,
    isWithin:
      x > yLabelWidth && y > coordinateLeftTopY && y < coordinateLeftTopY + verticalAxisHeight,
    clientX,
    clientY,
  };
};

const handleMouseMove = (e: MouseEvent) => {
  const { x, isWithin } = isWithinOrNot(e);
  if (isWithin) {
    // 显示
  } else {
    // 隐藏
  }
}

const handleMouseLeave = () => {
  // 隐藏
};

接下来我们还需要判断当前鼠标位于哪个刻度线的区域内,我们再写个函数。

const handleShowBarBackground = (x: number) => {
  const averageWidth = horizontalAxisWidth / chatData.length;
  // x为svg的横坐标位置,计算index为鼠标位于哪个x轴刻度区域内
  const index = Math.floor((x - yLabelWidth) / averageWidth);
  const currentItem = chatData[index];
  if (currentItem) {
    // 柱形背景色绘制
    barBgRender(currentItem.tickPosition);
  } else {
    handleHiddenBarBackground();
  }
};

下面我们就要绘制柱形的背景色了。来贴下代码。

HistogramChart({ data, config }: HistogramChartProps) {
  // ...

  // hover显示柱形背景色
  const barBgRef = useRef<SVGGElement>(null);
  const barBgRender = (x: number) => {
    if (barBgRef.current) {
      // 加6 为增加内边距
      const backgroundWith = barWidth + 6;
      // 只有用户第一次hover时才渲染dom,后续只需要更改位置属性
      if (barBgRef.current.firstChild) {
        barBgRef.current.children[0].setAttribute('x', String(x - backgroundWith / 2));
        barBgRef.current.children[0].setAttribute('width', String(backgroundWith));
      } else {
        barBgRef.current.innerHTML = `
            <rect
              x="${x - backgroundWith / 2}"
              y="${coordinateLeftTopY}"
              height="${verticalAxisHeight}"
              width="${backgroundWith + 4}"
              fill="#EEF2FF"
            />
          `;
      }
      barBgRef.current?.setAttribute('style', 'visibility: visible;');
    }
  };
  
  const handleShowBarBackground = (x: number) => {
    // ...
  };
 
  const handleHiddenBarBackground = () => {
    barBgRef.current?.setAttribute('style', 'visibility: hidden;');
  };
  
  const handleMouseMove = useThrottle((e: MouseEvent) => {
    const { x, isWithin } = isWithinOrNot(e);
    if (isWithin) {
      // 显示
      handleShowBarBackground(x)
    } else 
      // 隐藏
      handleHiddenBarBackground()
    }
  }, { wait: 50, trailing: false })

  const handleMouseLeave = () => {
    // 隐藏
    handleHiddenBarBackground()
  };

  return (
    <svg
      width={width}
      height={height}
      onMouseMove={handleMouseMove.run}
      onMouseLeave={handleMouseLeave}
    >
      {/* y轴 */}
      {yCoordinateAxisNode}
      {/* x轴 */}
      {xCoordinateAxisNode}
      {/* 柱形hover背景色 */}
      <g ref={barBgRef} />
      {/* 柱形 */}
      {barNode}
    </svg>
  );
}

代码如上,我们现在在svg增加一个<g ref={barBgRef} />元素,然后当hover时,给这个元素加上加上rect元素,用来渲染背景色即可。注意的是要根据鼠标位置计算背景色位置。

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

hover提示信息

继续完善交互,我们再增加hover时有提示窗显示,这个就不需要svg了,只需要绝对定位的div元素即可,我们继续。 我们先给svg外部加一层相对定位的容器

const containerRef = useRef<HTMLDivElement>(null);

<div ref={containerRef} className="rsc-container">
  <svg
    width={width}
    height={height}
    onMouseMove={handleMouseMove}
    onMouseLeave={handleMouseLeave}
  >
    ...
  </svg>
</div>

现在就是当鼠标hover在svg时,给containerRef的元素加上绝对定位的提示窗。同样的,处理前我们先了解下各个计算值。

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

如上,请耐心看完,我们目的是要计算提示窗的绝对定位位置。也许你会觉得看图麻烦,没关系直接上代码,结合图来看。

function HistogramChart({ data, config }: HistogramChartProps) {
  // ...

  const isWithinOrNot = (e: MouseEvent) => {
    const rect = e.currentTarget?.getBoundingClientRect();
    const { clientX, clientY } = e;
    const x = clientX - rect.left;
    const y = clientY - rect.top;
    return {
      x,
      y,
      isWithin:
        x > yLabelWidth && y > coordinateLeftTopY && y < coordinateLeftTopY + verticalAxisHeight,
      clientX,
      clientY,
    };
  };

  const containerRef = useRef<HTMLDivElement>(null);

  const tooltipsRef = useRef<HTMLDivElement>();
  const handleHiddenTooltips = () => {
    if (tooltipsRef.current) {
      tooltipsRef.current.style.visibility = 'hidden';
    }
  };

  const handleShowTooltips = (x: number, clientX: number, clientY: number) => {
    if (!containerRef.current) {
      return;
    }

    const averageWidth = horizontalAxisWidth / chatData.length;
    // x为svg的横坐标位置,计算index为鼠标位于哪个x轴刻度区域内
    const index = Math.floor((x - yLabelWidth) / averageWidth);

    // 挂载提示窗
    if (!tooltipsRef.current) {
      tooltipsRef.current = document.createElement('div');
      tooltipsRef.current.setAttribute('class', TOOLTIPS_CLASS_PREFIX);
      containerRef.current.appendChild(tooltipsRef.current);
    }

    // 显示tooltips
    const currentItem = data[index];
    if (currentItem) {
      const { dataset } = tooltipsRef.current;
      if (dataset.lastIndex !== String(index)) {
        dataset.lastIndex = String(index);

        tooltipsRef.current.innerHTML = `
                <div class="${TOOLTIPS_CLASS_PREFIX}-title">${currentItem.label}</div>
                  <ul class="${TOOLTIPS_CLASS_PREFIX}-list">
                    <li class="${TOOLTIPS_CLASS_PREFIX}-list-item" style="color: #14DE95;">
                      <span class="${TOOLTIPS_CLASS_PREFIX}-label">${currentItem.value.name}:</span>
                      <span class="${TOOLTIPS_CLASS_PREFIX}-val">${currentItem.value.value}</span>
                    </li>
                  </ul>
                `;
      }

      const { scrollWidth } = containerRef.current;
      const { left: containerLeft, top: containerTop } =
        containerRef.current.getBoundingClientRect();
      const { offsetHeight: tooltipsHeight, offsetWidth: tooltipsWidth } = tooltipsRef.current;
       // 浮窗定位(取最大/小值,是为了限制浮窗位置不会超出容器范围)
      tooltipsRef.current.setAttribute(
        'style',
        `top: ${Math.max(0, clientY - containerTop - tooltipsHeight - 20)}px; left: ${Math.min(
          scrollWidth - tooltipsWidth,
          Math.max(0, clientX - containerLeft - tooltipsWidth / 2),
        )}px; visibility: visible;`,
      );
    }
  };

  // 50ms的节流,可以让浮窗移动更丝滑
  const handleMouseMove = useThrottle(
    (e: MouseEvent) => {
      const { x, clientX, clientY, isWithin } = isWithinOrNot(e);
      if (isWithin) {
        handleShowBarBackground(x);
        handleShowTooltips(x, clientX, clientY);
      } else {
        handleHiddenBarBackground();
        handleHiddenTooltips();
      }
    },
    { wait: 50, trailing: false },
  );
  const handleMouseLeave = () => {
    handleHiddenBarBackground();
    handleHiddenTooltips();
  };
}

我们把样式代码也贴一下


.rsc-container {
  height: inherit;
  position: relative;
  line-height: 0;
}

.rsc-tooltips {
  padding: 6px 10px;
  position: absolute;
  border-radius: 4px;
  visibility: hidden;
  background: #fff;
  box-shadow: 0 6px 30px rgba(228, 231, 238, 0.6);
  transition: left 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s,
    top 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s;
  pointer-events: none;
  white-space: nowrap;
}

.rsc-tooltips::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -5px;
  width: 10px;
  height: 10px;
  margin-left: -5px;
  border-radius: 2px 0;
  transform: rotate(45deg);
  background: inherit;
}

.rsc-tooltips-title {
  margin-bottom: 3px;
  font-weight: 500;
  font-size: 14px;
  line-height: 20px;
  color: #4d535c;
}

.rsc-tooltips-list {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 12px;
  line-height: 17px;
}

.rsc-tooltips-list-item {
  display: flex;
  align-items: center;
}

.rsc-tooltips-list-item::before {
  content: '';
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  margin-right: 4px;
  background: currentcolor;
}

.rsc-tooltips-list-item + .rsc-tooltips-list-item {
  margin-top: 10px;
}

.rsc-tooltips-label {
  color: #4d535c;
}

.rsc-tooltips-val {
  font-weight: 700;
  font-size: 18px;
  line-height: 21px;
  color: #1f242e;
}

效果展示如下

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

宽高自适应

目前为止,我们的图表就算完成啦,但还有很多工作需要做,比如可配置化。我们接下来再讲个如何让图表不需要传入宽高,根据外部容器自适应。

我们使用size-sensor包,用来监听元素的宽高变化。它的用法非常简单。

import { bind, clear } from 'size-sensor';
 
// bind the event on element, will get the `unbind` function
const unbind = bind(document.querySelector('.container'), element => {
  // do what you want to to.
});

现在开始,我们在图表组件外层再包装一个组件,写一个div用来监听宽高元素。

function HistogramChartWrapper({ data, config }: HistogramChartProps) {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const { width: externalWidth, height: externalHeight, autoFit } = config;
  const [{ width, height }, setContainerSize] = useState<{
    width: number;
    height: number;
  }>(
    autoFit
      ? {
          // 自适应时传入的宽高无效
          width: 0,
          height: 0,
        }
      : {
          width: externalWidth ?? 640,
          height: externalHeight ?? 480,
        },
  );

  useEffect(() => {
    // 自适应容器宽高
    if (autoFit) {
      const unbind = bind(wrapperRef.current, (element) => {
        if (!element) {
          return;
        }
        // 获取元素宽高
        const size = getContainerSize(element);
        setContainerSize(size);
      });
      return unbind;
    }
  }, [autoFit]);

  return (
    <div ref={wrapperRef} style={{ width: '100%', height: '100%' }}>
      {width && height ? (
        <HistogramChart
          data={data}
          config={{
            ...config,
            width,
            height,
          }}
        />
      ) : (
        false
      )}
    </div>
  );
}

我们需要注意的是,这个div要设置高度100%,不然默认就为0了。我们看到还有一个函数getContainerSize没实现。实现如下,我们需要严谨点,万一用户外面加了一些样式,所以要把内边距除去。

type Size = {
  width: number;
  height: number;
};

const parseInt10 = (d: string) => (d ? parseInt(d, 10) : 0);

function getContainerSize(container: HTMLElement): Size {
  const style = getComputedStyle(container);

  const wrapperWidth = container.clientWidth || parseInt10(style.width);
  const wrapperHeight = container.clientHeight || parseInt10(style.height);

  const widthPadding = parseInt10(style.paddingLeft) + parseInt10(style.paddingRight);
  const heightPadding = parseInt10(style.paddingTop) + parseInt10(style.paddingBottom);

  return {
    width: wrapperWidth - widthPadding,
    height: wrapperHeight - heightPadding,
  };
}

展示效果

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

后续还有更多配置也可以支持自定义,另外我们还要考虑多组柱形图等等,在这里就不一一介绍了。

折线图

折线

我们有了绘制柱形图的经验,接下来绘制折线图将会更得心应手。这次我们考虑多组折线图的情况,我们约定传入的数据源如下。

const data = [
  {
    label: '2021',
    value: [
      { name: '参与人数', value: 10 },
      { name: '未参与人数', value: 10 },
    ],
  },
  {
    label: '2022',
    value: [
      { name: '参与人数', value: 24 },
      { name: '未参与人数', value: 24 },
    ],
  },
  {
    label: '2023',
    value: [
      { name: '参与人数', value: 16 },
      { name: '未参与人数', value: 16 },
    ],
  },
];

坐标系画法和柱形图一样,跳过。我们直接处理数据,绘制折线。讲一下接下来的思路,直接用 path 元素的 d 属性进行绘制。 假设我们计算得到d属性

<path d="M 110 97 L 258 188 L 406 161" />

图形展示如下

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

开始处理,同样我们先写个处理函数

function generateChartData(
  list: DataListItem[],
  {
    horizontalAxisWidth,
    yMaxValue,
    verticalAxisHeight,
    coordinateLeftTopY,
    yLabelWidth,
  }: LineGenerateDataConfigType,
): LineChartDataListItem[] {
  const chartData: LineChartDataListItem[] = [];
  const len = list.length;
  // 平分横向坐标宽度
  const averageWidth = horizontalAxisWidth / list.length;

  const genCategory = (v: ValueType, x: number, index: number): LineCategoryType => {
    // 计算y坐标点
    const yPosition = (1 - v.value / yMaxValue) * verticalAxisHeight + coordinateLeftTopY;

    return {
      ...v,
      yPosition,
      d: `${index === 0 ? 'M' : 'L'} ${x} ${yPosition}`,
    };
  };

  for (let i = 0; i < len; i++) {
    const item = list[i];
    let category: LineCategoryType[] = [];

    // x坐标刻度点
    const tickPosition = averageWidth * (i + 0.5) + yLabelWidth;

    // 多条折线图
    if (Array.isArray(item.value)) {
      category = item.value.map((c) => genCategory(c, tickPosition, i));
    } else if (Object.prototype.toString.call(item.value) === '[object Object]') {
      // 一条折线图
      category = [genCategory(item.value, tickPosition, i)];
    } else {
      throw new Error('value必须为对象或者数组');
    }

    chartData.push({
      tickPosition,
      category,
      label: item.label,
    });
  }
  return chartData;
}

如上,我们同时考虑了数据源是单条或多条折线的情况。我们遍历每条数据记录每个刻度点的坐标值,和计算 d 属性值。下面开始绘制折线

function LineChart({ data, config }: LineChartProps) {
  // ...

  const chatData = useMemo(
    () =>
      generateChartData(data, {
        horizontalAxisWidth,
        yMaxValue,
        verticalAxisHeight,
        yLabelWidth,
        coordinateLeftTopY,
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      // eslint-disable-next-line react-hooks/exhaustive-deps
      JSON.stringify(data),
      horizontalAxisWidth,
      yMaxValue,
      verticalAxisHeight,
      yLabelWidth,
    ],
  );

  // 折线
  const pathLineNode = useMemo(() => {
    if (chatData.length <= 0) {
      return null;
    }
    const { category } = chatData[0];
    return (
      <g>
        {category.map((c, index: number) => (
          <path
            key={`${index}_${c.name}`}
            d={chatData.map((item) => item.category[index].d).join('')}
            stroke={colors[index]}
            fill="none"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        ))}
      </g>
    );
  }, [chatData]);

  return (
    <div ref={containerRef} className="rsc-container">
      <svg
        width={width}
        height={height}
      >
        {/* y轴 */}
        {yCoordinateAxisNode}
        {/* x轴 */}
        {xCoordinateAxisNode}
        {/* 折线 */}
        {pathLineNode}
      </svg>
    </div>
  );
}

hover显示辅助线与辅助点

同样地,我们加一下交互,hover显示辅助线与辅助点,让体验更友好。鼠标移动在柱形图讲过了,我们直接看怎么绘制辅助线与辅助点。

function LineChart({ data, config }: LineChartProps) {
  // ...

  const crosshairsRef = useRef<SVGGElement>(null);
  const dotRef = useRef<SVGGElement>(null);

  const crosshairsRender = useCallback(
    (x: number) => {
      if (crosshairsRef.current) {
        const d = `M ${x} ${coordinateLeftTopY} L ${x} ${coordinateLeftTopY + verticalAxisHeight}`;
        if (crosshairsRef.current.firstChild) {
          crosshairsRef.current.children[0].setAttribute('d', d);
        } else {
          crosshairsRef.current.innerHTML = `
          <path
            d="${d}"
            stroke="#DAE2F5"
            fill="none"
            strokeWidth="1"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        `;
        }
        crosshairsRef.current?.setAttribute('style', 'visibility: visible;');
      }
    },
    [verticalAxisHeight, coordinateLeftTopY],
  );

  const dotRender = useCallback((item: LineChartDataListItem) => {
    if (dotRef.current) {
      if (dotRef.current.children.length > 0) {
        Array.prototype.map.call(dotRef.current.children, (g, index) => {
          g.children[0]?.setAttribute('cx', item.tickPosition);
          g.children[0]?.setAttribute('cy', item.category[index].yPosition);
          g.children[1]?.setAttribute('cx', item.tickPosition);
          g.children[1]?.setAttribute('cy', item.category[index].yPosition);
        });
      } else {
        dotRef.current.innerHTML = item.category
          .map(
            // 第一个circle为点的边框,第二个为圆心
            (c, i) => `
              <g>
                <circle r="6" cx="${item.tickPosition}" cy="${c.yPosition}" fill="#fff" />
                <circle r="4" cx="${item.tickPosition}" cy="${c.yPosition}" fill="${colors[i]}" />
              </g>
             `,
          )
          .join('');
      }
      dotRef.current?.setAttribute('style', 'visibility: visible;');
    }
  }, []);

  const handleHiddenAccessory = useCallback(() => {
    [dotRef.current, crosshairsRef.current].forEach((dom) =>
      dom?.setAttribute('style', 'visibility: hidden;'),
    );
  }, []);

  const handleShowAccessory = (x: number) => {
    const averageWidth = horizontalAxisWidth / chatData.length;
    // x为svg的横坐标位置,计算index为鼠标位于哪个x轴刻度区域内
    const index = Math.floor((x - yLabelWidth) / averageWidth);
    const currentItem = chatData[index];
    if (currentItem) {
      // 辅助线绘制
      crosshairsRender(currentItem.tickPosition);
      // 辅助点绘制
      dotRender(currentItem);
    } else {
      handleHiddenAccessory();
    }
  };

  const handleMouseMove = useThrottle(
    (e: MouseEvent) => {
      const { x, isWithin } = isWithinOrNot(e);
      if (isWithin) {
        handleShowAccessory(x);
      } else {
        handleHiddenAccessory();
      }
    },
    { wait: 50, trailing: false },
  );
  const handleMouseLeave = () => {
    handleHiddenAccessory();
  };

  return (
    <div ref={containerRef} className="rsc-container">
      <svg
        width={width}
        height={height}
        onMouseMove={handleMouseMove.run}
        onMouseLeave={handleMouseLeave}
      >
        {/* y轴 */}
        {yCoordinateAxisNode}
        {/* x轴 */}
        {xCoordinateAxisNode}
        {/* 辅助线 */}
        <g ref={crosshairsRef} />
        {/* 折线 */}
        {pathLineNode}
        {/* 辅助点 */}
        <g ref={dotRef} />
      </svg>
    </div>
  );
}

辅助线用 path 元素,辅助点用 circle 元素。因为前面我们已经处理好每个刻度点对应的坐标,我们绘制就不难。注意辅助点的顺序要在折线后面,也就是层级要比折线优先。

我们来看下效果

轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然

后续,我们同样可以支持配置等优化,和柱形图类似,就不再多介绍了。

最后

感谢您能坚持看到最后,希望对你有所收获,图表功能还有更多。有兴趣可以前往源代码查看 github.com/Zeng-J/reac…

也可以安装包快速使用。

yarn add rs-charts

第一个例子

import { Histogram } from 'rs-charts';

export default () => (
  <Histogram
    data={[
      { label: '2021', value: { name: '参与人数', value: 40 } },
      { label: '2022', value: { name: '参与人数', value: 20 } },
    ]}
    config={{
      autoFit: false,
      width: 400,
      height: 400,
    }}
  />
);

使用文档 zeng-j.github.io/react-svg-c…

转载自:https://juejin.cn/post/7259757499254227004
评论
请登录