Antv G2中自定义技巧📊

背景:当前项目需要将图表的依赖库更新为 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 }
  ]
}
相关推荐
shmily麻瓜小菜鸡1 小时前
前端“伪防盗链”方案
前端·javascript·vue.js·bootstrap·echarts
mayaairi1 小时前
JS数组完全指南(含十大操作详解)
开发语言·前端·javascript
IT_陈寒1 小时前
Python的线程池把我CPU跑满了,原来少传了个参数
前端·人工智能·后端
喜欢的名字被抢了1 小时前
写-Chrome-扩展时踩过的那些-MV3-的坑
前端·chrome
网安老伯2 小时前
网络安全基础要点知识介绍(非常详细),零基础入门到精通,看这一篇就够了
运维·前端·网络协议·web安全·网络安全·职场和发展
wuqingshun3141592 小时前
springBoot是如何通过main方法启动web项目的?
前端·spring boot·后端
xxwl5852 小时前
HTML 小总结:从骨架到枝叶,系统掌握网页结构
前端·html
●VON2 小时前
鸿蒙 PC Markdown 编辑器 Bridge 协议:原生外壳与 Web 内核的状态同步
前端·华为·编辑器·harmonyos·鸿蒙
MrDJun2 小时前
分享一款免费实用的网页变动监控工具:PageWatch.tech
前端·javascript·vue.js