背景:当前项目需要将图表的依赖库更新为 Antd G2,所以为了还原原有样式效果,就衍生出了一些需要自定义组件模块才可以还原的内容
关键词:图表 、自定义
一、如何自定义X轴tick动态变化的数量
说明:因为自定义tick数量需要结合数据的数量来设置,若数据仅有一个,但设置的tick有5个,则此设置就不合理,所以至少需要有一个保底的tick数量来兜底(例如1个)
效果展示
按照[5, 6, 4, 3, 2, 1]的优先级设置tick数量






核心代码
当数据节点的数量超过tick之间的节点数时,就会重新计算展示新的tick数据排布,所以末尾的数据节点不会超过tick之间的节点数量
ini
/**
* x 轴 tick 数量与过滤策略
*
* 目标:在数据点较多时控制 x 轴显示的 tick 数量,使 tick 从左到右均匀分布,
* 且优先使用 5 个 tick,其次 6、4、3、2、1;同时保证「末尾被跳过的数据点数量」不超过一个 step,
* 避免最后一段空白过多、视觉上不均衡。
*/
const dataLength = chartData.length;
/** 期望的 tick 数量优先级:优先 5,其次 6、4、3、2、1 */
const preferredOrder = [5, 6, 4, 3, 2, 1];
/** 用于过滤 x 轴 tick 的函数:返回 true 的 index 会显示为 tick */
let tickFilter: ((datum: any, index: number) => boolean) | undefined;
if (dataLength > 1) {
/** 最终选中的 tick 数量,若循环内未命中则保持 null,后面用 1 兜底 */
let n: number | null = null;
for (const candidate of preferredOrder) {
if (candidate > dataLength) continue;
if (candidate === 1) {
n = 1;
break;
}
/** 均匀分布时,相邻 tick 之间的数据点间隔(向下取整) */
const step = Math.max(1, Math.floor((dataLength - 1) / (candidate - 1)));
/** 最后一个被显示的 tick 对应的数据索引:(candidate-1) * step */
const lastShownIndex = (candidate - 1) * step;
/** 最后一个 tick 之后、到数据末尾之间被「跳过」的数据点数量 */
const hiddenAtEnd = dataLength - 1 - lastShownIndex;
/** 仅当末尾空白 < step 时才采用该 candidate,避免最后一段空白过大 */
if (hiddenAtEnd < step) {
n = candidate;
break;
}
}
const resolvedN = n ?? 1;
if (resolvedN === 1) {
/** 只显示 1 个 tick 时,仅保留第一个数据点对应的 tick */
tickFilter = (_datum: any, index: number) => index === 0;
} else {
const step = Math.max(1, Math.floor((dataLength - 1) / (resolvedN - 1)));
/** 均匀选取:index 为 0, step, 2*step, ... 且总数不超过 resolvedN 个 */
tickFilter = (_datum: any, index: number) =>
index % step === 0 && index / step < resolvedN;
}
}
二、如何自定义Y轴tick的数量
说明:当我们及需要使用Antd G2图表自动生成的最大值节点值,又想要控制有几个tick节点,这时就需要在scale中设置tickMethod,如果想要在特定情境设置Y轴tick,可以在tickMethod中设置确定的最大值(当数据都为0 的场景想要自定义时,需要结合domainMin和domain来配置)
效果展示
使用自动生成的最大值作为最大值节点

当所有值都为0时,设置Y轴tick为3个,且节点值分别为0、1、2

核心代码
当我们需要调整tick的数量的时候,只需要调整tickMethod方法中newTickCount的值
typescript
chart.scale("y", {
domainMin: 0,
domain: allValuesZero ? [0, 2] : undefined, // 当所有值都为0时,设置为[0,2],否则为undefined
nice: true,
tickMethod: (start: number | Date, end: number | Date, tickCount: number) => {
const newTickCount = 3
// 确保 start 和 end 是数字类型
const startNum = typeof start === 'number' ? start : 0;
const endNum = typeof end === 'number' ? end : 0;
// 计算步长
const step = (endNum - startNum) / (newTickCount - 1);
// 生成刻度数组:第一个值为 start,最后一个值为 end,中间值均匀分布
const ticks: number[] = [];
for (let i = 0; i < newTickCount; i++) {
ticks.push(startNum + step * i);
}
return ticks;
},
})
三、如何自定义legend图例
说明:当我们需要在chart初始化时就配置自定义内容,这时legend关于主轴方向及主轴方向的对其位置就不会生效
效果展示

核心代码
less
chart.legend({
color: {
position: 'top',
render: (items: any[]) => {
const container = document.createElement("div");
const root = createRoot(container);
root.render(
<div style={{ display: "flex", gap: "10px" }}>
{items.map((item: any) => (
<span key={item.id}
style={{ cursor: "pointer", backgroundColor: '#F6F6F7', borderRadius: "3px", padding: '4px 12px 4px 8px', display: "flex", alignItems: "center" }}
{...{ "legend-value": item.id }}>
<span style={{ width: '12px', height: '2px', backgroundColor: item.color, marginRight: "8px", borderRadius: "2px" }} />
<span
style={{
fontSize: '14px',
color: '#222',
fontWeight: 400,
lineHeight: '20px',
maxWidth: '120px',
// --- 自动换行 ---
wordBreak: 'break-word',
overflowWrap: 'break-word',
}}
title={item.label}
>
{item.label}+自定义自动换行
</span>
</span>
))}
</div>
);
return container;
},
},
})
「完整代码」
说明:该代码结合了以上自定义场景的需求,涉及具体需求如下:
- x轴:tick数量优先级按照5个、6个、4个、3个、2个、1个来展示
- y轴:始终展示3个tick,当数据都为0时,tick的值分别为0、1、2
- legend图例:自定义,每项要有背景、圆角值、图标、颜色、label内容可自定义
组件代码
php
import { Chart } from "@antv/g2";
import { FC, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
const formatSymbol = (price: string | number): string => {
const { symbol_left = '', symbol_right = '' } = window.STORE || {};
return `${symbol_left}${price}${symbol_right}`;
};
// 指标类型枚举
const enum IndicatorType {
REGISTER_RATIO = 'register_ratio',
CONVERSION_RATIO = 'conversion_ratio',
// REPURCHASE_RATIO = 'repurchase_ratio',
AOV = 'aov',
}
interface LineChartProps {
data: API.StoreStrategy.Trend[];
title?: string;
}
const LineChart: FC<LineChartProps> = ({ data, title }) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current || !data.length) return;
const chart = new Chart({
container: containerRef.current,
autoFit: true,
height: 250,
marginLeft: 0,
marginRight: 0,
paddingRight: 0,
paddingTop: 55,
paddingBottom: 5,
});
// 转换数据格式:确保 dt 是时间格式,value 是数字,并添加 label 用于 legend
// 根据 title 格式化 legend 显示的值
const isCurrency = title === IndicatorType.AOV;
const formatValue = (val: number) => {
if (isCurrency) {
return formatSymbol(val);
}
return `${val}%`;
};
// 格式化时间为 MM-DD 格式
const formatDate = (date: string | Date) => {
const d = new Date(date);
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${month}-${day}`;
};
// 注意:value 必须保持为数字类型用于绘制,格式化只用于显示;若数据带 label 则按多系列绘制多条线
const chartData = data.map(d => ({
dt: formatDate(d.dt),
value: Number(d.value), // 保持为数字类型
label: d.label ?? title,
}));
// 检测所有值是否都为0
const maxValue = Math.max(...chartData.map(d => d.value));
const minValue = Math.min(...chartData.map(d => d.value));
const allValuesZero = maxValue === 0 && minValue === 0;
// 计算 x 轴 tick 过滤策略:按唯一 dt 数量(多系列时按 x 轴点数而非总数据条数)
const uniqueDtValues = [...new Set(chartData.map(d => d.dt))];
const dataLength = uniqueDtValues.length;
const preferredOrder = [5, 6, 4, 3, 2, 1];
let tickFilter: ((datum: any, index: number) => boolean) | undefined;
if (dataLength > 1) {
let n: number | null = null;
for (const candidate of preferredOrder) {
if (candidate > dataLength) continue;
if (candidate === 1) {
n = 1;
break;
}
const step = Math.max(1, Math.floor((dataLength - 1) / (candidate - 1)));
const lastShownIndex = (candidate - 1) * step;
const hiddenAtEnd = dataLength - 1 - lastShownIndex;
if (hiddenAtEnd < step) {
n = candidate;
break;
}
}
const resolvedN = n ?? 1;
if (resolvedN === 1) {
tickFilter = (_datum: any, index: number) => index === 0;
} else {
const step = Math.max(1, Math.floor((dataLength - 1) / (resolvedN - 1)));
tickFilter = (_datum: any, index: number) =>
index % step === 0 && index / step < resolvedN;
}
}
chart
.line()
.style({
lineWidth: 2,
point: {
r: 0, // 设置点的半径为 0,不显示点
},
})
.data(chartData)
.encode("x", "dt")
.encode("y", "value")
.encode("color", "label")
.state({
active: {
point: {
r: 0, // 确保 active 状态下点的半径也为 0
},
},
})
.scale("x", {
type: "point", // 改为 point 类型,因为数据是格式化后的字符串
range: [0.05, 0.95],
align: dataLength <= 1 ? 0.05 : undefined,
nice: false,
})
.scale("y", {
domainMin: 0,
domain: allValuesZero ? [0, 2] : undefined, // 当所有值都为0时,设置为[0,2],否则为undefined
nice: true,
tickMethod: (start: number | Date, end: number | Date, tickCount: number) => {
const newTickCount = 3
// 确保 start 和 end 是数字类型
const startNum = typeof start === 'number' ? start : 0;
const endNum = typeof end === 'number' ? end : 0;
// 计算步长
const step = (endNum - startNum) / (newTickCount - 1);
// 生成刻度数组:第一个值为 start,最后一个值为 end,中间值均匀分布
const ticks: number[] = [];
for (let i = 0; i < newTickCount; i++) {
ticks.push(startNum + step * i);
}
return ticks;
},
})
.scale("color", {
range: ["#6D8FF0", "#E1AE35"],
})
.axis("y", {
title: false,
labelFontSize: 12,
labelFill: "#6D7175",
labelFontWeight: 500,
labelOpacity: 0.8,
tickStroke: "#000000",
tickOpacity: 0.04,
grid: true,
gridLineWidth: 1,
gridLineDash: [2, 2],
})
.axis("x", {
title: false,
labelFontSize: 12,
labelFill: "#6D7175",
labelFontWeight: 500,
labelOpacity: 0.8,
tickFilter: tickFilter, // 当 tick 过多时自动过滤显示
line: true,
lineStroke: "#6D7175",
lineLineWidth: 1,
lineOpacity: 1,
lineLineDash: [0, 0], // 设置为实线(0,0 表示无虚线)
tickStroke: "#6D7175",
tickLineWidth: 1,
tickLength: 4,
})
.legend({
color: {
position: 'top',
layout: {
justifyContent: 'center', // 水平居中
},
size: 60,
length: 300,
crossPadding: 10,
render: (items: any[]) => {
const container = document.createElement("div");
const root = createRoot(container);
root.render(
<div style={{ display: "flex", gap: "10px" }}>
{items.map((item: any) => (
<span key={item.id}
style={{ cursor: "pointer", backgroundColor: '#F6F6F7', borderRadius: "3px", padding: '4px 12px 4px 8px', display: "flex", alignItems: "center" }}
{...{ "legend-value": item.id }}>
<span style={{ width: '12px', height: '2px', backgroundColor: item.color, marginRight: "8px", borderRadius: "2px" }} />
<span
style={{
fontSize: '14px',
color: '#222',
fontWeight: 400,
lineHeight: '20px',
maxWidth: '120px',
// --- 自动换行 ---
wordBreak: 'break-word',
overflowWrap: 'break-word',
}}
title={item.label}
>
{item.label}+自定义自动换行
</span>
</span>
))}
</div>
);
return container;
},
},
})
.tooltip({
items: [
{
channel: "y",
valueFormatter: (d: any) => formatValue(d),
},
],
});
chart.render();
// 清理函数
return () => {
chart.destroy();
};
}, [data, title]);
return (
<div className="flex-1 min-w-0 my-[10px]">
<div ref={containerRef} style={{ width: "100%", marginTop: '16px' }} />
</div>
);
};
export default LineChart;
测试数据
css
{
"data": [
{ "dt": "2024-01-01", "label": "Tokyo", "value": 7 },
{ "dt": "2024-01-01", "label": "London", "value": 3.9 },
{ "dt": "2024-02-01", "label": "Tokyo", "value": 6.9 },
{ "dt": "2024-02-01", "label": "London", "value": 4.2 },
{ "dt": "2024-03-01", "label": "Tokyo", "value": 9.5 },
{ "dt": "2024-03-01", "label": "London", "value": 5.7 },
{ "dt": "2024-04-01", "label": "Tokyo", "value": 14.5 },
{ "dt": "2024-04-01", "label": "London", "value": 8.5 },
{ "dt": "2024-05-01", "label": "Tokyo", "value": 18.4 },
{ "dt": "2024-05-01", "label": "London", "value": 11.9 },
{ "dt": "2024-06-01", "label": "Tokyo", "vaÏlue": 21.5 },
{ "dt": "2024-06-01", "label": "London", "value": 15.2 },
{ "dt": "2024-07-01", "label": "Tokyo", "value": 25.2 },
{ "dt": "2024-07-01", "label": "London", "value": 17 },
{ "dt": "2024-08-01", "label": "Tokyo", "value": 26.5 },
{ "dt": "2024-08-01", "label": "London", "value": 16.6 },
{ "dt": "2024-09-01", "label": "Tokyo", "value": 23.3 },
{ "dt": "2024-09-01", "label": "London", "value": 14.2 },
{ "dt": "2024-10-01", "label": "Tokyo", "value": 18.3 },
{ "dt": "2024-10-01", "label": "London", "value": 10.3 },
{ "dt": "2024-11-01", "label": "Tokyo", "value": 13.9 },
{ "dt": "2024-11-01", "label": "London", "value": 6.6 },
{ "dt": "2024-12-01", "label": "Tokyo", "value": 9.6 },
{ "dt": "2024-12-01", "label": "London", "value": 4.8 }
]
}