Skip to main content

Unovis

Unovis (Apache-2.0) is a modular set of chart components from F5: graphs, Sankey diagrams, chord diagrams, maps and XY charts. @unovis/ts is the library itself, framework-free, and @unovis/react wraps it for React (VisSingleContainer, VisGraph, VisSankey, ...). A component reads your data through accessors, functions of each datum, so a node's fill is nodeFill: (n) => ... and a flow's colour linkColor: (l) => .... @sportsdataverse/sdvplot/unovis builds those accessors' answers for teams: team colours, and logos and headshots for a Graph's nodes. It never imports Unovis (install @unovis/ts 1.7.1 or later beside it, an optional peer, and @unovis/react for React); every piece is a plain value or function, built anywhere, a server included. It is synchronous: load the league first (loadLeague).

Team colours​

teamColorScale(league, teams) returns a function: call it with the team value a datum holds and it answers that team's colour, so a Sankey's flows take linkColor: (l) => color(l.team), a Graph's links linkStroke and its nodes nodeFill or nodeStroke the same way. Give it every team the chart draws, so the colours are worked out together. Its domain keeps the values exactly as you gave them, numbers as numbers, each once (12 and "12" are both kept), and its range holds their colours. The colours belong to the resolved team, so 12, "12" and "KC" all answer the Chiefs' red, whichever your data holds. A value that names none of its teams answers fallback (grey by default), with one warning.

Pass background (a hex colour) for a chart on a dark card: a team colour under 3:1 contrast against it is swapped for the team's other colour or lifted until it reads, and the teams' colours are kept apart (CIE76 ΔE 20). The Eagles' midnight green, for one, does not read on #111827 and is replaced. teamColorPalette returns the same colours as a list, for anything that takes colours by position, such as an XY component's color with one y accessor per team.

With JavaScript on, Unovis draws this figure here.

[
  {
    "from": "team:PHI",
    "to": "band:Own 21-40",
    "drives": 5,
    "color": "#004c54"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Punt",
    "drives": 2,
    "color": "#004c54"
  },
  {
    "from": "team:KC",
    "to": "band:Own 1-20",
    "drives": 4,
    "color": "#e31837"
  },
  {
    "from": "band:Own 1-20",
    "to": "result:Punt",
    "drives": 2,
    "color": "#e31837"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Touchdown",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "team:KC",
    "to": "band:Own 21-40",
    "drives": 8,
    "color": "#e31837"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Punt",
    "drives": 4,
    "color": "#e31837"
  },
  {
    "from": "team:PHI",
    "to": "band:Own 1-20",
    "drives": 2,
    "color": "#004c54"
  },
  {
    "from": "band:Own 1-20",
    "to": "result:Interception",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "team:PHI",
    "to": "band:Own 41 to opp. 41",
    "drives": 4,
    "color": "#004c54"
  },
  {
    "from": "band:Own 41 to opp. 41",
    "to": "result:Field Goal",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Interception Touchdown",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Own 1-20",
    "to": "result:Interception",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "team:PHI",
    "to": "band:Opp. 40 and in",
    "drives": 2,
    "color": "#004c54"
  },
  {
    "from": "band:Opp. 40 and in",
    "to": "result:Touchdown",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:End of Half",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 1-20",
    "to": "result:Field Goal",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Downs",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Own 41 to opp. 41",
    "to": "result:Touchdown",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 1-20",
    "to": "result:Touchdown",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Field Goal",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Fumble",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Opp. 40 and in",
    "to": "result:Field Goal",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "band:Own 21-40",
    "to": "result:Touchdown",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Own 41 to opp. 41",
    "to": "result:Downs",
    "drives": 1,
    "color": "#004c54"
  },
  {
    "from": "team:KC",
    "to": "band:Own 41 to opp. 41",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Own 41 to opp. 41",
    "to": "result:Touchdown",
    "drives": 1,
    "color": "#e31837"
  },
  {
    "from": "band:Own 41 to opp. 41",
    "to": "result:End of Game",
    "drives": 1,
    "color": "#004c54"
  }
]
import { SUPER_BOWL_LIX_DRIVES } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColorScale } from "@sportsdataverse/sdvplot/unovis";

await loadLeague("nfl");
// where a drive started, in yards from the opponent's end zone
const band = (yardline: number) =>
yardline >= 80
? "Own 1-20"
: yardline >= 60
? "Own 21-40"
: yardline >= 41
? "Own 41 to opp. 41"
: "Opp. 40 and in";
// three columns: the team with the ball, where its drive started, how the drive ended (ESPN's words)
const counts = new Map<string, { source: string; target: string; team: string; value: number }>();
const add = (source: string, target: string, team: string) => {
const key = `${source}>${target}>${team}`;
const link = counts.get(key) ?? { source, target, team, value: 0 };
link.value += 1;
counts.set(key, link);
};
for (const d of SUPER_BOWL_LIX_DRIVES) {
add(`team:${d.team}`, `band:${band(d.yardline_100)}`, d.team);
add(`band:${band(d.yardline_100)}`, `result:${d.result}`, d.team);
}
const links = [...counts.values()];
const ids = [...new Set(links.flatMap((l) => [l.source, l.target]))];
const nodes = ids.map((id) => ({
id,
label: id.slice(id.indexOf(":") + 1),
team: id.startsWith("team:") ? id.slice(5) : undefined,
}));
// every flow in the colour of the team that had the ball; the band and result nodes stay grey
const color = teamColorScale("nfl", ["KC", "PHI"]);

// In the browser: new SingleContainer(el, { component: new Sankey(config()), height: 420 }, data())
export const browser = {
lib: "unovis",
component: "Sankey",
config: () => ({
label: (n: (typeof nodes)[number]) => n.label,
nodeColor: (n: (typeof nodes)[number]) => (n.team === undefined ? "#9ca3af" : color(n.team)),
linkColor: (l: (typeof links)[number]) => color(l.team),
nodeWidth: 14,
}),
data: () => ({ nodes: nodes.map((n) => ({ ...n })), links: links.map((l) => ({ ...l })) }),
width: 680,
height: 420,
label: `Super Bowl LIX's 26 drives, Kansas City's in red and Philadelphia's in green, flowing from the team to where the drive started and on to how it ended: ${links
.filter((l) => l.source.startsWith("band:"))
.map((l) => `${l.team} ${l.source.slice(5)} to ${l.target.slice(7)}, ${l.value}`)
.join("; ")}`,
} as const;

// Unovis draws only in a browser; without JavaScript the page shows each flow, its drives and its colour
links.map((l) => ({ from: l.source, to: l.target, drives: l.value, color: color(l.team) }));

Logos and headshots in a Graph​

A Unovis Graph draws a node's icon from its nodeIcon accessor. A text answer is drawn as text; an answer that starts with # is drawn as an SVG <use> of that id, nodeIconSize px square, from the container's svgDefs. logoIcons(league, teams) uses that: it returns a function for nodeIcon that answers # and the id of a <symbol> holding the team's logo, and its svgDefs holds those symbols, for the container (svgDefs on SingleContainer or VisSingleContainer). Each symbol keeps its image's own aspect, so the logo fits the icon's square uncut, and the node's shape (its nodeFill and nodeStroke) shows round it. Every value naming a team finds its logo, as for colours; the variant option picks another logo, such as "dark" for a dark card. headshotIcons(league, players) does the same for ESPN athlete ids, numbers or strings, or nflverse gsis ids with idSystem: "gsis" (after loadGsis()). The symbol ids are a function of the image, so two charts on a page can share them; to put logos and headshots in one chart, give the container both sets' svgDefs, joined.

A team with no logo archived has no icon (the accessor answers nothing, and Unovis draws an empty icon), and sdvplot warns once. A gsis id with no headshot does the same. An ESPN id's headshot URL is built without asking ESPN whether the photo exists, so an id ESPN has no photo for keeps an empty icon, with no warning.

With JavaScript on, Unovis draws this figure here.

[
  {
    "receiver": "Xavier Worthy",
    "targets": 8,
    "headshot": "#sdv-headshot-fq1qbb"
  },
  {
    "receiver": "Travis Kelce",
    "targets": 6,
    "headshot": "#sdv-headshot-1bi3819"
  },
  {
    "receiver": "DeAndre Hopkins",
    "targets": 5,
    "headshot": "#sdv-headshot-1lg4drr"
  },
  {
    "receiver": "JuJu Smith-Schuster",
    "targets": 2,
    "headshot": "#sdv-headshot-1mzz1lt"
  },
  {
    "receiver": "Hollywood Brown",
    "targets": 6,
    "headshot": "#sdv-headshot-muk7zd"
  },
  {
    "receiver": "Kareem Hunt",
    "targets": 1,
    "headshot": "#sdv-headshot-1k962ek"
  },
  {
    "receiver": "Isiah Pacheco",
    "targets": 2,
    "headshot": "#sdv-headshot-8ztnvw"
  },
  {
    "receiver": "Noah Gray",
    "targets": 1,
    "headshot": "#sdv-headshot-15ia7al"
  },
  {
    "receiver": "Samaje Perine",
    "targets": 1,
    "headshot": "#sdv-headshot-tahvjx"
  }
]
import { SUPER_BOWL_LIX_KC_PASSER, SUPER_BOWL_LIX_KC_TARGETS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { headshotIcons, teamColorScale } from "@sportsdataverse/sdvplot/unovis";

await loadLeague("nfl");
// the quarterback and the nine Chiefs he threw to, by ESPN athlete id (a string, as ESPN serves it)
const nodes = [
{
id: SUPER_BOWL_LIX_KC_PASSER,
name: "Patrick Mahomes",
team: "KC",
targets: SUPER_BOWL_LIX_KC_TARGETS.reduce((n, r) => n + r.targets, 0),
},
...SUPER_BOWL_LIX_KC_TARGETS.map((r) => ({ id: r.player, name: r.name, team: "KC", targets: r.targets })),
];
const links = SUPER_BOWL_LIX_KC_TARGETS.map((r) => ({
source: SUPER_BOWL_LIX_KC_PASSER,
target: r.player,
team: "KC",
targets: r.targets,
}));
const heads = headshotIcons(
"nfl",
nodes.map((n) => n.id),
);
const color = teamColorScale("nfl", ["KC"]);

// In React: <VisSingleContainer data={data()} svgDefs={heads.svgDefs} height={460}><VisGraph {...config()} />
// </VisSingleContainer>, from @unovis/react
export const browser = {
lib: "unovis-react",
component: "Graph",
config: () => ({
layoutType: "force",
nodeSize: (n: (typeof nodes)[number]) => (n.id === SUPER_BOWL_LIX_KC_PASSER ? 64 : 44),
nodeIcon: (n: (typeof nodes)[number]) => heads(n.id),
nodeIconSize: (n: (typeof nodes)[number]) => (n.id === SUPER_BOWL_LIX_KC_PASSER ? 56 : 38),
nodeFill: "#ffffff",
nodeStroke: (n: (typeof nodes)[number]) => color(n.team),
nodeStrokeWidth: 4,
nodeLabel: (n: (typeof nodes)[number]) => n.name,
nodeSubLabel: (n: (typeof nodes)[number]) =>
n.id === SUPER_BOWL_LIX_KC_PASSER ? `${n.targets} passes` : `${n.targets} targets`,
linkStroke: (l: (typeof links)[number]) => color(l.team),
linkWidth: (l: (typeof links)[number]) => 1.5 * l.targets,
}),
data: () => ({ nodes: nodes.map((n) => ({ ...n })), links: links.map((l) => ({ ...l })) }),
svgDefs: heads.svgDefs,
width: 680,
height: 460,
label: `Patrick Mahomes' 32 targets in Super Bowl LIX as a network: a link from him to each Chief he threw to, as wide as the targets, each node the player's headshot: ${SUPER_BOWL_LIX_KC_TARGETS.map((r) => `${r.name} ${r.targets}`).join(", ")}`,
} as const;

// Unovis draws only in a browser; without JavaScript the page shows each target count and the headshot it draws
links.map((l) => ({
receiver: nodes.find((n) => n.id === l.target)?.name,
targets: l.targets,
headshot: heads(l.target),
}));

The passing network above is drawn with @unovis/react: the example hands its config and data to a VisSingleContainer holding a VisGraph. The chart below is drawn with @unovis/ts, the same config passed to new Graph(...) inside new SingleContainer(element, ...).

With JavaScript on, Unovis draws this figure here.

[
  {
    "week": 1,
    "winner": "KC",
    "loser": "BAL",
    "color": "#e31837"
  },
  {
    "week": 2,
    "winner": "KC",
    "loser": "CIN",
    "color": "#e31837"
  },
  {
    "week": 3,
    "winner": "KC",
    "loser": "ATL",
    "color": "#e31837"
  },
  {
    "week": 4,
    "winner": "KC",
    "loser": "LAC",
    "color": "#e31837"
  },
  {
    "week": 5,
    "winner": "KC",
    "loser": "NO",
    "color": "#e31837"
  },
  {
    "week": 7,
    "winner": "KC",
    "loser": "SF",
    "color": "#e31837"
  },
  {
    "week": 8,
    "winner": "KC",
    "loser": "LV",
    "color": "#e31837"
  },
  {
    "week": 9,
    "winner": "KC",
    "loser": "TB",
    "color": "#e31837"
  },
  {
    "week": 10,
    "winner": "KC",
    "loser": "DEN",
    "color": "#e31837"
  },
  {
    "week": 11,
    "winner": "BUF",
    "loser": "KC",
    "color": "#515ebd"
  },
  {
    "week": 12,
    "winner": "KC",
    "loser": "CAR",
    "color": "#e31837"
  },
  {
    "week": 13,
    "winner": "KC",
    "loser": "LV",
    "color": "#e31837"
  },
  {
    "week": 14,
    "winner": "KC",
    "loser": "LAC",
    "color": "#e31837"
  },
  {
    "week": 15,
    "winner": "KC",
    "loser": "CLE",
    "color": "#e31837"
  },
  {
    "week": 16,
    "winner": "KC",
    "loser": "HOU",
    "color": "#e31837"
  },
  {
    "week": 17,
    "winner": "KC",
    "loser": "PIT",
    "color": "#e31837"
  },
  {
    "week": 18,
    "winner": "DEN",
    "loser": "KC",
    "color": "#006d98"
  }
]
import { KC_PHI_GAMES_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoIcons, teamColorScale } from "@sportsdataverse/sdvplot/unovis";

await loadLeague("nfl");
// Kansas City's 17 games, each a link from the winner to the loser, as wide as the margin
const links = KC_PHI_GAMES_2024.filter((g) => g.home_team === "KC" || g.away_team === "KC").map((g) => {
const homeWon = g.home_score > g.away_score;
return {
source: homeWon ? g.home_team : g.away_team,
target: homeWon ? g.away_team : g.home_team,
team: homeWon ? g.home_team : g.away_team,
margin: Math.abs(g.home_score - g.away_score),
week: g.week,
};
});
const teams = [...new Set(links.flatMap((l) => [l.source, l.target]))];
const nodes = teams.map((team) => ({ id: team, team }));
// the card's background: every team colour keeps 3:1 contrast on it and the teams stay distinct, and the logos are
// their dark variants
const BG = "#111827";
const color = teamColorScale("nfl", teams, { background: BG });
const logos = logoIcons("nfl", teams, { variant: "dark" });

// In the browser: new SingleContainer(el, { component: new Graph(config()), svgDefs: logos.svgDefs, height: 480 },
// data())
export const browser = {
lib: "unovis",
component: "Graph",
config: () => ({
layoutType: "force",
nodeSize: 46,
nodeIcon: (n: (typeof nodes)[number]) => logos(n.team),
nodeIconSize: 34,
nodeFill: "#1f2937",
nodeStroke: (n: (typeof nodes)[number]) => color(n.team),
nodeStrokeWidth: 4,
nodeLabel: (n: (typeof nodes)[number]) => n.team,
linkStroke: (l: (typeof links)[number]) => color(l.team),
linkWidth: (l: (typeof links)[number]) => 1 + l.margin / 3,
linkArrow: "single",
}),
data: () => ({ nodes: nodes.map((n) => ({ ...n })), links: links.map((l) => ({ ...l })) }),
svgDefs: logos.svgDefs,
// Unovis's label colours for a dark card, in the docs' light and dark modes alike
style: {
background: BG,
"--vis-graph-node-label-text-color": "#e5e7eb",
"--vis-dark-graph-node-label-text-color": "#e5e7eb",
"--vis-graph-node-label-background": BG,
"--vis-dark-graph-node-label-background": BG,
},
width: 680,
height: 480,
label: `The Chiefs' 2024 regular season on a dark background: each team's logo, and a link from each game's winner to its loser in the winner's colour, as wide as the margin: ${links
.map((l) => `week ${l.week} ${l.source} beat ${l.target} by ${l.margin}`)
.join("; ")}`,
} as const;

// Unovis draws only in a browser; without JavaScript the page shows each game and the colour of its link
links.map((l) => ({ week: l.week, winner: l.source, loser: l.target, color: color(l.team) }));

Sankey diagrams take colours, not logos​

A Sankey's nodeIcon is written into an SVG text element, so it can only be a character or a few (Unovis's own examples use an icon font). There is no image path in a Sankey's types or its drawing code, and sdvplot does not reach into its internals to add one: a Sankey takes team colours for its nodes and flows, and team names as its labels.

Dark mode​

Unovis colours its own labels, outlines and empty links with CSS variables (--vis-graph-node-label-text-color, --vis-sankey-node-label-color, ...), and switches them to their dark values on a page whose html has data-theme="dark" (Docusaurus' dark mode), or whose html or body has the class theme-dark or dark-theme. A chart on a dark card of its own, whatever the page's mode, sets the variables on the card: the light name and its --vis-dark- twin, since Unovis reads the twin in a dark page. The team colours come from teamColorScale with the card's background, and the logos from logoIcons with variant: "dark".

Svelte 5​

@unovis/svelte 1.7.1 declares Svelte 4 (svelte ^4.0.0) as its peer, and this site runs Svelte 5, so it is not used here. In Svelte 5, draw with @unovis/ts directly: in an action or an $effect, create a SingleContainer on the element with your component (new Graph(config), new Sankey(config)), call its setData when the data changes and its destroy when the element goes. The sdvplot pieces are the same plain values and functions as above.

TypeScript and bundlers​

@unovis/react 1.7.1 imports each @unovis/ts component by its folder (@unovis/ts/components/graph), and @unovis/ts has no exports map to say which file that is. Node's own ES module loader refuses such a folder import, so @unovis/react cannot be imported by plain Node (a server render or a test runner that hands node_modules to Node); import it where a bundler resolves it. webpack 5 refuses @unovis/react's folder imports as well (it resolves an ES module package's imports strictly), until a rule turns resolve.fullySpecified off for node_modules/@unovis; this site's docs build has that rule. @unovis/ts's own imports name their files in full, so webpack and Node take it as it is.

Unovis 1.7.1's type declarations re-export folders without file extensions (export * from './components'), which TypeScript follows only with "moduleResolution": "bundler" (or the older "node"). Under "node16" or "nodenext", @unovis/ts resolves but exports no types, so import { Graph } from "@unovis/ts" fails to type-check. A bundled app (Vite, webpack, Next.js) can use bundler resolution. sdvplot's own declarations name no Unovis type, so @sportsdataverse/sdvplot/unovis types check under either. The examples on this site hand Unovis plain configs for this reason.

What installing Unovis brings​

@unovis/ts 1.7.1 depends on, among others, all of d3, three ^0.135, leaflet 1.7.1, maplibre-gl ^6.7, elkjs and @emotion/css, for its globe, map and graph-layout components. A package manager installs all of them. three 0.135 is older than the three sdvplot's Three.js layer asks for (0.186.1 or later), so a project that uses both installs two copies side by side; they do not meet at runtime. Its maplibre-gl range (^6.7) takes the copy sdvplot's MapLibre layer asks for (6.14.0 or later), so one copy serves both: this repository's lockfile holds only 6.14.0. What reaches the browser depends on what you import. Unovis marks only its stylesheet entry as having side effects, so a bundler keeps just the components a page names: import them by name (import { Graph, SingleContainer } from "@unovis/ts"), never the whole namespace. This site's two Unovis drawers import SingleContainer, Graph and Sankey (and their React wrappers), and their library chunks hold no Three.js, Leaflet, MapLibre or ELK code. The Graph loads its layout code (d3-force, dagre, ELK) on demand, only for the layout it is given.

API​

  • teamColorScale(league, teams, o?): a team colour function, with domain and range. Options: which ("primary" or "secondary"), season, idSystem, fallback, background.
  • teamColorPalette(league, teams, o?): the colours as a list, the same options.
  • logoIcons(league, teams, o?): a nodeIcon function, with domain, range (the # ids) and svgDefs. Options: season, variant, idSystem.
  • headshotIcons(league, players, o?): the same for player ids. Option: idSystem ("espn" or "gsis").

See the API reference for every option. The drive and target data are ESPN's, from the Super Bowl LIX game summary; the 2024 results are nflverse's schedules.