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

前言

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

展示

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

柱形图

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

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

属性 说明
width 图表宽度 640
height 图表高度 480
labelFontSize 标签字体大小 12
yLabelWidth Y 轴标签宽度 36
yLabelPaddingRight Y 轴标签右边距 8
xLabelPaddingTop X 轴标签上边距 8
yMaxValue Y 轴最大值 100
yTickCount Y 轴刻度数量 5
barWidth 柱子宽度 20

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

javascript 复制代码
  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 坐标系的高度
yGap y 轴刻度线的间距

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

y轴

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

typescript 复制代码
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>
  );
}

显示如下

x轴与柱形

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

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

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

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

typescript 复制代码
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,
      },
    };
  });
}

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

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

typescript 复制代码
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>
  );
}

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

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

typescript 复制代码
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>
  );
}

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

hover柱形背景色

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

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

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

typescript 复制代码
// 判断鼠标是否在坐标系内
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 = () => {
  // 隐藏
};

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

typescript 复制代码
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();
  }
};

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

typescript 复制代码
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元素,用来渲染背景色即可。注意的是要根据鼠标位置计算背景色位置。

hover提示信息

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

jsx 复制代码
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的元素加上绝对定位的提示窗。同样的,处理前我们先了解下各个计算值。

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

typescript 复制代码
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();
  };
}

我们把样式代码也贴一下

css 复制代码
.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;
}

效果展示如下

宽高自适应

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

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

typescript 复制代码
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用来监听宽高元素。

typescript 复制代码
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没实现。实现如下,我们需要严谨点,万一用户外面加了一些样式,所以要把内边距除去。

typescript 复制代码
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,
  };
}

展示效果

后续还有更多配置也可以支持自定义,另外我们还要考虑多组柱形图等等,在这里就不一一介绍了。有兴趣可以前往源代码查看 github.com/Zeng-J/reac...

折线图

折线

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

typescript 复制代码
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属性

jsx 复制代码
<path d="M 110 97 L 258 188 L 406 161" />

图形展示如下

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

typescript 复制代码
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 属性值。下面开始绘制折线

typescript 复制代码
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显示辅助线与辅助点,让体验更友好。鼠标移动在柱形图讲过了,我们直接看怎么绘制辅助线与辅助点。

typescript 复制代码
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 元素。因为前面我们已经处理好每个刻度点对应的坐标,我们绘制就不难。注意辅助点的顺序要在折线后面,也就是层级要比折线优先。

我们来看下效果

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

最后

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

也可以安装包快速使用。

bash 复制代码
yarn add rs-charts

第一个例子

jsx 复制代码
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...

相关推荐
openKaka_1 小时前
为什么 React 18 之后使用 createRoot,而不是 ReactDOM.render
前端·javascript·react.js
老王以为2 小时前
从源码到架构:React useActionState 深度剖析
前端·javascript·react.js
天蓝色的鱼鱼3 小时前
当AI开始替我写代码,我还要纠结选Vue还是React吗?
vue.js·react.js·ai编程
空中海19 小时前
01 React Native 基础、核心组件与布局体系
javascript·react native·react.js
空中海19 小时前
05 React架构设计、项目实践与专家清单
前端·react.js·前端框架
空中海1 天前
04 工程化、质量体系与 React 生态
前端·ubuntu·react.js
空中海1 天前
03 性能、动画与 React Native 新架构
react native·react.js·架构
空中海1 天前
02 React Native状态、导航、数据流与设备能力
javascript·react native·react.js
空中海1 天前
04 React Native工程化、质量、发布与生态选型
javascript·react native·react.js
郑生zs1 天前
Hooks-useEffect
react.js