G2 and Ant Design Charts
G2 is AntV's grammar of graphics: a chart is a spec of marks (point, interval,
line, image, ...) over shared scales, given to chart.options(spec). Ant Design Charts
(@ant-design/plots, which @ant-design/charts re-exports) is its React layer: <Scatter>, <Column>, <Line>
and the rest take a config that becomes a G2 spec. @sportsdataverse/sdvplot/g2 builds the spec pieces both need:
team colours as a G2 colour scale, team logos, wordmarks and headshots as G2 image marks, and logos as a band axis'
labels. It never imports G2 (install @antv/g2 5.4.8 or later beside it, an optional peer, and for axisLogos
@antv/component 2.1.12 or later; the examples here run Ant Design Charts 2.6.8), so every piece can be built
anywhere, a server included; only the chart's render() needs a browser. It is synchronous: load the league first (loadLeague). The charts below are drawn by G2 in your browser.
BizCharts, the older React layer, draws with G2 4, whose spec is different; it is not supported.
Team colours
teamColorScale(league, teams) returns G2's scale.color for those teams, { type: "ordinal", domain, range }: the
domain is the team values exactly as your data holds them (each once, in order) and the range their colours. G2 matches
the domain to the data by exact value, so pass a numeric team column (ESPN ids, say) as numbers: "12" would never
match 12, and G2 would colour the unmatched teams by position instead. Encode the team field
as the mark's colour (encode: { color: "team" }, or colorField: "team" in Ant Design Charts) and give the scale
as scale: { color }: each team draws in its own colour, and the legend lists them in this order. teamColorPalette
returns the colours alone, for anything that takes them by position.
Pass background (a hex colour) for a dark chart: a team colour under 3:1 contrast against it is swapped for the
team's other colour or lifted until it reads, and the colours are kept apart from each other. G2's dark theme
paints no background of its own (it is transparent), so pass the colour of the page behind the chart; the dark example
below sits on #141414.
Logos as image marks
logoMark(rows, { x, y, team, league }) returns a G2 image mark: one logo per row, size px tall (default 24) and
centred on the row's x and y fields, read through the chart's own scales, so a logo sits exactly where a point of
that row would. wordmarkMark draws wordmarks at their own width, and headshotMark(rows, { x, y, player, league })
ESPN headshots by player id (idSystem: "gsis" for nflverse ids, after loadGsis()). size is in px, as G2 sizes
an image mark; a fraction such as 0.1 is refused with an InputError, not drawn as a sub-pixel image. alpha sets
the opacity. A row whose team has no logo (or whose player has no headshot), or with no x or y, is left out with
one warning; nothing draws a broken image.
The mark is plain data and goes where G2 takes a mark:
- G2: last in a view's
children, so it draws on top:chart.options({ type: "view", data, children: [..., logoMark(rows, o)] }). A view's own options reach every child, so the mark keeps each whole row (and adds itssdv_fields): a view-levelencodefinds the same values in it, and a style callback you add to the mark can read the row (fade each logo by wins, say). It is out of the tooltip and the legend: put a point mark under the logos for a tooltip, as the scatter below does. - Ant Design Charts: in the config's
annotations. Ant Design Charts hands each annotation to G2 as it is, whereas a mark in itschildrenwould also take the plot'sxField,yFieldandcolorField, which would point the logos at the plot's fields instead of their own.
With JavaScript on, G2 draws this figure here.
[
{
"id": "22",
"offense": "0.068",
"defense": "0.051",
"size": 26
},
{
"id": "1",
"offense": "0.041",
"defense": "0.044",
"size": 26
},
{
"id": "33",
"offense": "0.197",
"defense": "-0.018",
"size": 26
},
{
"id": "2",
"offense": "0.187",
"defense": "-0.003",
"size": 26
},
{
"id": "29",
"offense": "-0.041",
"defense": "0.154",
"size": 26
},
{
"id": "3",
"offense": "-0.075",
"defense": "-0.001",
"size": 26
},
{
"id": "4",
"offense": "0.106",
"defense": "0.055",
"size": 26
},
{
"id": "5",
"offense": "-0.175",
"defense": "0.017",
"size": 26
},
{
"id": "6",
"offense": "-0.090",
"defense": "0.068",
"size": 26
},
{
"id": "7",
"offense": "0.010",
"defense": "-0.098",
"size": 26
},
{
"id": "8",
"offense": "0.155",
"defense": "-0.045",
"size": 26
},
{
"id": "9",
"offense": "0.080",
"defense": "-0.068",
"size": 26
},
{
"id": "34",
"offense": "-0.040",
"defense": "-0.054",
"size": 26
},
{
"id": "11",
"offense": "-0.047",
"defense": "0.006",
"size": 26
},
{
"id": "30",
"offense": "-0.022",
"defense": "0.127",
"size": 26
},
{
"id": "12",
"offense": "0.069",
"defense": "0.006",
"size": 26
},
{
"id": "14",
"offense": "0.034",
"defense": "0.050",
"size": 26
},
{
"id": "24",
"offense": "0.045",
"defense": "-0.056",
"size": 26
},
{
"id": "13",
"offense": "-0.131",
"defense": "0.014",
"size": 26
},
{
"id": "15",
"offense": "-0.040",
"defense": "-0.021",
"size": 26
},
{
"id": "16",
"offense": "0.037",
"defense": "-0.089",
"size": 26
},
{
"id": "17",
"offense": "-0.079",
"defense": "0.083",
"size": 26
},
{
"id": "18",
"offense": "-0.059",
"defense": "0.036",
"size": 26
},
{
"id": "19",
"offense": "-0.092",
"defense": "0.071",
"size": 26
},
{
"id": "20",
"offense": "0.001",
"defense": "0.046",
"size": 26
},
{
"id": "21",
"offense": "0.111",
"defense": "-0.086",
"size": 26
},
{
"id": "23",
"offense": "-0.029",
"defense": "-0.021",
"size": 26
},
{
"id": "26",
"offense": "-0.016",
"defense": "-0.018",
"size": 26
},
{
"id": "25",
"offense": "0.061",
"defense": "0.052",
"size": 26
},
{
"id": "27",
"offense": "0.132",
"defense": "0.017",
"size": 26
},
{
"id": "10",
"offense": "-0.101",
"defense": "0.050",
"size": 26
},
{
"id": "28",
"offense": "0.139",
"defense": "0.043",
"size": 26
}
]import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { drawnMarks, logoMark } from "@sportsdataverse/sdvplot/g2";
await loadLeague("nfl");
// EPA per play from the season totals (nflverse play by play, rushes and passes); every team, so nothing to pick
const rows = NFL_TEAM_EPA_2024.map((t) => ({
team: t.team,
offense: t.off_epa / t.off_plays,
defense: t.def_epa / t.def_plays,
}));
// for the chart's accessible name: the best offence and the stingiest defence, read from the rows
const by = (better: (a: number, b: number) => boolean, key: "offense" | "defense") =>
rows.reduce((best, r) => (better(r[key], best[key]) ? r : best));
const offence = by((a, b) => a > b, "offense");
const defence = by((a, b) => a < b, "defense");
const signed = (v: number) => `${v > 0 ? "+" : ""}${v.toFixed(3)}`;
// one logo per team, 26 px tall, centred on its (offense, defense)
const logos = logoMark(rows, { x: "offense", y: "defense", team: "team", league: "nfl", size: 26 });
// In the browser: new Chart({ container, width: 640, height: 480 }), chart.options(spec()), chart.render().
export const browser = {
lib: "g2",
spec: () => ({
type: "view",
data: rows,
// a defence that allows fewer EPA per play is better: up
scale: { y: { range: [0, 1], nice: true }, x: { nice: true } },
axis: {
x: { title: "Offensive EPA per play" },
y: { title: "Defensive EPA per play allowed (better is up)" },
},
children: [
{ type: "lineX", data: [0], style: { stroke: "#8c8c8c", lineDash: [4, 4] } },
{ type: "lineY", data: [0], style: { stroke: "#8c8c8c", lineDash: [4, 4] } },
// an invisible point under each logo holds the tooltip
{
type: "point",
encode: { x: "offense", y: "defense", size: 13 },
style: { opacity: 0 },
tooltip: {
title: "team",
items: [
{ field: "offense", valueFormatter: ".3f" },
{ field: "defense", valueFormatter: ".3f" },
],
},
},
logos,
],
}),
width: 640,
height: 480,
label: `The ${rows.length} NFL teams of 2024 by offensive EPA per play (right is better) and defensive EPA per play allowed (up is better), each drawn as its logo: ${offence.team} the best offence (${signed(offence.offense)}), ${defence.team} the stingiest defence (${signed(defence.defense)})`,
} as const;
// G2 draws only in a browser; without JavaScript the page shows what the logo mark draws: [team id, x, y, px, logo]
drawnMarks(logos).map(([id, x, y, size]) => ({
id,
offense: Number(x).toFixed(3),
defense: Number(y).toFixed(3),
size,
}));
With JavaScript on, Ant Design Charts draws this figure here.
[
{
"team": "KC",
"pf": 385,
"pa": 326,
"record": "15-2",
"color": "#e31837"
},
{
"team": "LAC",
"pf": 402,
"pa": 301,
"record": "11-6",
"color": "#007bc7"
},
{
"team": "DEN",
"pf": 425,
"pa": 311,
"record": "10-7",
"color": "#002244"
},
{
"team": "LV",
"pf": 309,
"pa": 434,
"record": "4-13",
"color": "#000000"
},
{
"team": "BUF",
"pf": 525,
"pa": 368,
"record": "13-4",
"color": "#00338d"
},
{
"team": "MIA",
"pf": 345,
"pa": 364,
"record": "8-9",
"color": "#008e97"
},
{
"team": "NYJ",
"pf": 338,
"pa": 404,
"record": "5-12",
"color": "#003f2d"
},
{
"team": "NE",
"pf": 289,
"pa": 417,
"record": "4-13",
"color": "#002244"
}
]import { Scatter } from "@ant-design/plots";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoMark, teamColorScale } from "@sportsdataverse/sdvplot/g2";
await loadLeague("nfl");
// the AFC West and East, picked by id
const TEAMS = ["KC", "LAC", "DEN", "LV", "BUF", "MIA", "NYJ", "NE"];
const rows = TEAMS.map((team) => {
const s = STANDINGS.find((r) => r.team === team);
if (s === undefined) throw new Error(`${team} is not in STANDINGS`);
return { team, pf: s.pf, pa: s.pa, record: `${s.wins}-${s.losses}` };
});
// The config is a G2 spec underneath: a G2 scale.color, and an image mark in `annotations`, which Ant Design Charts
// hands to G2 as it is (a mark in `children` would take the plot's xField, yField and colorField too)
const config = {
data: rows,
xField: "pf",
yField: "pa",
colorField: "team",
scale: { color: teamColorScale("nfl", TEAMS), y: { range: [0, 1] } }, // fewer points allowed is better: up
legend: false,
axis: { x: { title: "Points for" }, y: { title: "Points against (fewer is up)" } },
tooltip: { title: "team", items: [{ field: "record", name: "W-L" }] },
annotations: [logoMark(rows, { x: "pf", y: "pa", team: "team", league: "nfl", size: 30 })],
height: 400,
};
// In the browser: <Scatter {...config} />, here at 560 px wide
export const browser = {
lib: "ant-design-charts",
chart: () => <Scatter {...config} />,
width: 560,
height: 400,
label:
"2024 points for against points against for the AFC West and East, each team drawn as its logo over a point in its colour: Buffalo scored most (525), the Chargers allowed fewest (301)",
} as const;
// Ant Design Charts draws only in a browser; without JavaScript the page shows each team's points and colour
rows.map((r, i) => ({ ...r, color: config.scale.color.range[i] }));
On a line chart, give logoMark only the last row of each line, and each line ends in its team's logo. This one is
dark: the colours come from teamColorScale(..., { background: "#141414" }), and Ant Design Charts' containerStyle
paints that background.
With JavaScript on, Ant Design Charts draws this figure here.
[
{
"team": "KC",
"margin": 59,
"color": "#e31837"
},
{
"team": "LAC",
"margin": 101,
"color": "#007bc7"
},
{
"team": "DEN",
"margin": 114,
"color": "#fb4f14"
},
{
"team": "LV",
"margin": -125,
"color": "#a5acaf"
}
]import { Line } from "@ant-design/plots";
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoMark, teamColorScale } from "@sportsdataverse/sdvplot/g2";
await loadLeague("nfl");
const TEAMS = ["KC", "LAC", "DEN", "LV"];
const BACKGROUND = "#141414"; // the page behind G2's dark theme (its own background is transparent)
// one row per team and week: the margin summed to date (the bye adds nothing)
const rows = TEAMS.flatMap((team) => {
const t = AFC_WEEKS_2024.find((w) => w.team === team);
if (t === undefined) throw new Error(`${team} is not in AFC_WEEKS_2024`);
return t.margins.map((_, i) => ({
team,
week: i + 1,
margin: t.margins.slice(0, i + 1).reduce<number>((sum, m) => sum + (m ?? 0), 0),
}));
});
// the colours read on the dark background (3:1): Denver's navy and the Raiders' black are lifted
const color = teamColorScale("nfl", TEAMS, { background: BACKGROUND });
const ends = rows.filter((r) => r.week === 18);
const config = {
data: rows,
xField: "week",
yField: "margin",
colorField: "team",
scale: { color, x: { domain: [1, 18.6] } }, // room right of week 18 for the end logos
theme: "dark",
style: { lineWidth: 2.5 },
axis: { x: { title: "Week", tickCount: 6 }, y: { title: "Point differential, season to date" } },
legend: { color: { position: "top" } },
// the logos ride on the lines' last points; in `annotations`, the plot's fields do not reach them
annotations: [logoMark(ends, { x: "week", y: "margin", team: "team", league: "nfl", size: 24 })],
height: 360,
containerStyle: { background: BACKGROUND, height: 360 },
};
// In the browser: <Line {...config} />, here at 560 px wide on the dark background
export const browser = {
lib: "ant-design-charts",
chart: () => <Line {...config} />,
width: 560,
height: 360,
label:
"The 2024 AFC West's point differential summed week by week on a dark background, each line in its team's dark-mode colour and ending in its logo: Denver +114, the Chargers +101, Kansas City +59, Las Vegas -125",
} as const;
// Ant Design Charts draws only in a browser; without JavaScript the page shows each team's final margin and colour
ends.map((r) => ({
team: r.team,
margin: r.margin,
color: color.range[color.domain.indexOf(r.team)],
}));
Logos on the axis
axisLogos({ league }) returns options for a horizontal band axis whose categories are teams: spread them into
axis.x (in G2 and in Ant Design Charts alike), beside any of your own such as title. It uses G2's labelRender,
which draws each label as HTML: the team's logo, size px tall, centred under its column. A category with no logo
keeps its text, with one warning. The options also turn off G2's label hiding and rotation (each logo box is one band
wide, so they never overlap) and draw the labels at full opacity (G2's theme fades text labels to 0.45).
With JavaScript on, G2 draws this figure here.
[
{
"team": "KC",
"wins": 15,
"color": "#e31837"
},
{
"team": "BUF",
"wins": 13,
"color": "#00338d"
},
{
"team": "LAC",
"wins": 11,
"color": "#007bc7"
},
{
"team": "DEN",
"wins": 10,
"color": "#002244"
},
{
"team": "MIA",
"wins": 8,
"color": "#008e97"
},
{
"team": "NYJ",
"wins": 5,
"color": "#003f2d"
},
{
"team": "NE",
"wins": 4,
"color": "#002244"
},
{
"team": "LV",
"wins": 4,
"color": "#000000"
}
]import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { axisLogos, teamColorScale } from "@sportsdataverse/sdvplot/g2";
await loadLeague("nfl");
// the eight AFC teams picked by id, most wins first
const TEAMS = ["KC", "BUF", "LAC", "DEN", "MIA", "NYJ", "NE", "LV"];
const rows = TEAMS.map((team) => {
const s = STANDINGS.find((r) => r.team === team);
if (s === undefined) throw new Error(`${team} is not in STANDINGS`);
return { team, wins: s.wins };
}).sort((a, b) => b.wins - a.wins);
// each column in its team's colour: G2's scale.color, keyed by the team values the data holds
const color = teamColorScale(
"nfl",
rows.map((r) => r.team),
);
// In the browser: new Chart({ container, width: 560, height: 360 }), chart.options(spec()), chart.render().
export const browser = {
lib: "g2",
spec: () => ({
type: "interval",
data: rows,
encode: { x: "team", y: "wins", color: "team" },
scale: { color },
legend: false,
axis: {
// G2's labelRender: each team's label is its logo, 28 px tall, centred under its column
x: { ...axisLogos({ league: "nfl", size: 28 }), title: false },
y: { title: "Wins (2024)" },
},
}),
width: 560,
height: 360,
label:
"2024 wins for the AFC West and East, most first, each column in its team's colour with the team's logo under it: Kansas City 15, Buffalo 13, the Chargers 11, Denver 10, Miami 8, the Jets 5, New England and Las Vegas 4",
} as const;
// G2 draws only in a browser; without JavaScript the page shows each team's wins and colour
rows.map((r, i) => ({ ...r, color: color.range[i] }));
Three limits come with HTML labels, and all are G2's:
-
axisLogosneeds@antv/component2.1.12 or later, G2's axis package (an optional peer of sdvplot, so a package manager warns about an older one). Where G2 puts an HTML label changed in that patch release: up to 2.1.11 it centred each label's box on its tick, and from 2.1.12 (2026-09-21) it puts the box's left edge there.axisLogosis laid out for 2.1.12, which G2 5.4.8 installs now; under a lockfile that still holds 2.1.11, each logo sits half a column to the left, and updating@antv/componentputs it back. -
Each label's box is one band wide. A wordmark (
markType: "wordmark") wider than its band overflows the box into its neighbours' bands; give fewer teams the axis, or a smallersize. -
G2 lays an HTML label out one band wide along a horizontal axis only. On a vertical axis (a bar chart, or a column chart under
coordinate: { transform: [{ type: "transpose" }] }) it has no width to centre in. There, draw the logos as alogoMarkinstead: give each team a row at the base of its bar (0for bars from zero), and each logo is centred on the axis line beside its bar. Hide the team axis' own labels if they would show beside them: undertransposethe teams are still thexchannel, so that isaxis: { x: { label: false } }. -
HTML labels are page elements laid over the chart, not pixels on its canvas: an export of the canvas (
toDataURL, Ant Design Charts'downloadImage) leaves them out. Image marks are on the canvas and export with it.
Ant Design Charts in React
Every piece is a plain value, so it goes straight into a config: scale: { color: teamColorScale(...) },
annotations: [logoMark(...)], axis: { x: axisLogos(...) }. Ant Design Charts compares the new config with the old
one on every render (a deep compare, functions by identity) and updates the chart when they differ. The marks' style
functions are the same on every call, but axisLogos builds a new labelRender each time: build the config once,
outside the component or in a useMemo, and a re-render with the same data does not redraw the chart. G2 draws only
in the browser, and the marks' style functions cannot cross from a React server component to a client one: build the
config in the client component, after loadLeague.
Reading back what was drawn
drawnMarks(spec) lists every image of every sdvplot mark in a mark, a list of marks, a G2 spec (its children, at
any depth) or an Ant Design Charts config (its children and annotations): [id, x, y, size in px, url], with the
size read from the mark's own style. Tests use it to check what a chart will draw without drawing it; the scatter
above shows its output where JavaScript is off.
In the gallery: G2 and Ant Design Charts.