轻松实现!用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个属性
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轴的坐标轴线,把坐标轴文本也补上。
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年的参与人数的柱形图
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,
},
};
});
}
我们结合图示例,来解释计算值代表的意思
现在开始画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>
);
}
现在我们的坐标系就完成了
下面主要的一步,就是绘制柱形,但前面处理好数据了,绘制也是比较简单。
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
函数用来判断是否在坐标系内
// 判断鼠标是否在坐标系内
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元素,用来渲染背景色即可。注意的是要根据鼠标位置计算背景色位置。
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的元素加上绝对定位的提示窗。同样的,处理前我们先了解下各个计算值。
如上,请耐心看完,我们目的是要计算提示窗的绝对定位位置。也许你会觉得看图麻烦,没关系直接上代码,结合图来看。
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;
}
效果展示如下
宽高自适应
目前为止,我们的图表就算完成啦,但还有很多工作需要做,比如可配置化。我们接下来再讲个如何让图表不需要传入宽高,根据外部容器自适应。
我们使用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,
};
}
展示效果
后续还有更多配置也可以支持自定义,另外我们还要考虑多组柱形图等等,在这里就不一一介绍了。
折线图
折线
我们有了绘制柱形图的经验,接下来绘制折线图将会更得心应手。这次我们考虑多组折线图的情况,我们约定传入的数据源如下。
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" />
图形展示如下
开始处理,同样我们先写个处理函数
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 元素。因为前面我们已经处理好每个刻度点对应的坐标,我们绘制就不难。注意辅助点的顺序要在折线后面,也就是层级要比折线优先。
我们来看下效果
后续,我们同样可以支持配置等优化,和柱形图类似,就不再多介绍了。
最后
感谢您能坚持看到最后,希望对你有所收获,图表功能还有更多。有兴趣可以前往源代码查看 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,
}}
/>
);
转载自:https://juejin.cn/post/7259757499254227004