Logos, wordmarks, headshots
A logo on a chart is an image URL, and the hard part is picking the right one: the logo a team used that season, the dark-background version, a wordmark instead of a crest. sdvplot answers with URLs into an archive of every mark (each file named by its sha256, so a URL never changes what it shows), chosen from the league data in the package. Nothing here downloads an image; the browser loads each URL when it draws it.
A logo URL, by season
logoUrl(team, league, { season }) returns the archived logo that team used that season. logoUrlSync is the same
once the league is loaded.
https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/16/16a2c7e0cd5dcf2e6bdc646c0b22bf03e575f505aa4b843a0ebfdaf19650257b.png
import { logoUrl } from "@sportsdataverse/sdvplot";
await logoUrl("KC", "nfl", { markType: "wordmark" });
import * as Plot from "@observablehq/plot";
import { logoUrl, logoUrlSync } from "@sportsdataverse/sdvplot";
// logoUrl loads the league itself; logoUrlSync needs it loaded, which the first call just did.
const rows = [
{ season: "2010", src: await logoUrl("LV", "nfl", { season: 2010 }) },
{ season: "2019", src: logoUrlSync("LV", "nfl", { season: 2019 }) },
{ season: "2024", src: logoUrlSync("LV", "nfl", { season: 2024 }) },
];
Plot.plot({
height: 140,
y: { axis: null },
x: { type: "point", label: null }, // seasons as categories, not numbers
marks: [Plot.image(rows, { x: "season", src: "src", width: 80, height: 80, frameAnchor: "middle" })],
});
Choosing a mark: variants and ranking
selectMark returns the chosen mark's full row (source, variant, archive URL); variant: "dark" asks for the
dark-background version (VARIANTS lists them all) and markType: "wordmark" the wordmark. marks returns every
bundled mark for a team, best first, and compareMarks is that ranking for rows you filter or merge yourself.
{
"light": "default espn https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/7e/7ea8154cdbff5db84d248f235c1f1c78a1a8b4cb8c14335b5886ec10ee20b00e.png",
"dark": "dark espn https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/d1/d1eda281e4c4ff5bcb45a9186bbc906bc62262d4c30ac26950951e5f983319b7.png",
"wordmark": "default nflverse https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/35/35d8c2e4a7466b87e32f24a0cbb38a6138e70c02c1708fb49109a74d9841783b.png",
"ranked": [
"logo dark (espn, rank 0)",
"logo primary_logo_on_primary_color (espn, rank 0)",
"logo primary_logo_on_secondary_color (espn, rank 0)",
"logo primary_logo_black (espn, rank 0)",
"logo secondary_logo_black (espn, rank 0)",
"logo primary_logo_on_black_color (espn, rank 0)",
"logo primary_logo_on_white_color (espn, rank 0)",
"logo secondary_logo_on_black_color (espn, rank 0)",
"logo secondary_logo_on_primary_color (espn, rank 0)",
"logo secondary_logo_on_secondary_color (espn, rank 0)",
"logo secondary_logo_on_white_color (espn, rank 0)",
"logo default (espn, rank 0)",
"logo primary_logo_white (espn, rank 0)",
"logo secondary_logo_white (espn, rank 0)",
"logo grayscale (espn, rank 0)",
"logo scoreboard (espn, rank 0)",
"logo scoreboard_dark (espn, rank 0)",
"wordmark default (nflverse, rank 1)",
"logo squared (nflverse, rank 1)"
]
}import { compareMarks, marks, selectMark, selectMarkSync } from "@sportsdataverse/sdvplot";
// Green Bay ships a distinct dark-background logo (many teams reuse one image for both).
const light = await selectMark("GB", "nfl");
const dark = selectMarkSync("GB", "nfl", { variant: "dark" }); // the league is loaded now
const wordmark = selectMarkSync("GB", "nfl", { markType: "wordmark" });
// marks() returns the bundled rows already ranked; compareMarks is that ranking, for rows you filter or merge.
const ranked = [...(await marks("GB", "nfl"))].sort(compareMarks);
{
light: light && `${light.variant} ${light.source} ${light.archive_url}`,
dark: dark && `${dark.variant} ${dark.source} ${dark.archive_url}`,
wordmark: wordmark && `${wordmark.variant} ${wordmark.source} ${wordmark.archive_url}`,
ranked: ranked.map((r) => `${r.mark_type} ${r.variant} (${r.source}, rank ${r.source_rank})`),
};
The full manifest
The bundled data keeps the marks the charts use. marks(team, league, { full: true }) reads the complete archive
manifest instead: one download from MANIFEST_URL per process. fetchManifest, parseManifestCsv and
manifestMarks are the steps it is built from, for a copy you fetched or cached yourself.
[ "logo primary_logo_on_white_color …-…", "logo secondary_logo_on_white_color …-…", "logo primary_logo_white …-…", "logo secondary_logo_white …-…", "logo default …-…", "logo primary_logo_black …-…", "logo secondary_logo_on_secondary_color …-…", "logo secondary_logo_black …-…", "logo grayscale …-…", "logo secondary_logo_on_black_color …-…", "logo secondary_logo_on_primary_color …-…", "logo scoreboard …-…", "logo primary_logo_on_black_color …-…", "logo dark …-…", "logo scoreboard_dark …-…", "logo primary_logo_on_primary_color …-…", "logo primary_logo_on_secondary_color …-…", "logo dark 1960-2019", "logo default 1960-2019", "wordmark default …-…", "logo squared …-…", "logo squared 1960-2019", "wordmark default 1960-2019" ]
import { marks } from "@sportsdataverse/sdvplot";
// `full: true` downloads the CDN manifest once per process (about 18 MB); the default reads the bundled shard.
// The output on this page was prerendered offline, where MANIFEST_URL answers from the 66-row test fixture
// (fixtures/sdvplot/manifest_sample.csv), not the real file, which may list more.
const rows = await marks("LV", "nfl", { full: true });
rows.map((r) => `${r.mark_type} ${r.variant} ${r.valid_from ?? "…"}-${r.valid_to ?? "…"}`);
{
"MANIFEST_URL": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/manifest/marks.csv",
"rows": 66,
"sameRows": true,
"nflMarks": 48,
"best": [
"13 logo primary_logo_on_white_color https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/03/03b8871f5d130de07d28772d7fea9f1f350917f692ddf572928d9e2b521b0e1d.png",
"13 logo secondary_logo_on_white_color https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/03/03b8871f5d130de07d28772d7fea9f1f350917f692ddf572928d9e2b521b0e1d.png",
"13 logo primary_logo_white https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/0a/0aba4f9d3bc01eb9ffbe7c52fcb2b2603b37832cab227c71cc1205a0e2b6f3fd.png"
]
}import {
MANIFEST_URL,
fetchManifest,
loadLeague,
manifestMarks,
parseManifestCsv,
} from "@sportsdataverse/sdvplot";
// Downloaded from MANIFEST_URL and parsed once per process (about 18 MB); later calls reuse it.
const rows = await fetchManifest();
// parseManifestCsv is the parser fetchManifest uses, for a copy you downloaded yourself.
const own = parseManifestCsv(await (await fetch(MANIFEST_URL)).text());
// manifestMarks maps one league's rows onto its teams through the shard's aliases, best first.
const nfl = manifestMarks("nfl", rows, (await loadLeague("nfl")).aliases);
{
MANIFEST_URL,
rows: rows.length,
sameRows: own.length === rows.length,
nflMarks: nfl.length,
best: nfl.slice(0, 3).map((m) => `${m.team_id} ${m.mark_type} ${m.variant} ${m.archive_url}`),
};
Headshots
headshotUrl(playerId, league) builds an ESPN headshot URL from the player id alone, in every league
ESPN_HEADSHOT_LEAGUES lists; each league's own CDN has its own builder. HEADSHOT_ASPECT is the width-to-height
ratio of an ESPN headshot, for sizing the box.
https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3139477.png
import { headshotUrl } from "@sportsdataverse/sdvplot";
headshotUrl("3139477", "nfl"); // Patrick Mahomes, by ESPN athlete id
{
"espn": {
"Patrick Mahomes": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3139477.png",
"Justin Herbert": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4038941.png",
"Bo Nix": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4426338.png"
},
"LeBron James (nba 2544)": "https://cdn.nba.com/headshots/nba/latest/260x190/2544.png",
"A'ja Wilson (wnba 1628932)": "https://cdn.wnba.com/headshots/wnba/latest/260x190/1628932.png",
"Shohei Ohtani (mlb 660271)": "https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/660271/headshot/67/current.png",
"Connor McDavid (nhl 8478402)": "https://assets.nhle.com/mugs/nhl/latest/8478402.png",
"HEADSHOT_ASPECT": "1.376",
"ESPN_HEADSHOT_LEAGUES": {
"nfl": "nfl",
"nba": "nba",
"wnba": "wnba",
"mlb": "mlb",
"nhl": "nhl",
"cfb": "college-football",
"mbb": "mens-college-basketball",
"wbb": "womens-college-basketball"
}
}import { STANDINGS } from "@sportsdataverse/examples/data";
import {
ESPN_HEADSHOT_LEAGUES,
HEADSHOT_ASPECT,
headshotUrl,
mlbHeadshotUrl,
nbaHeadshotUrl,
nhlHeadshotUrl,
wnbaHeadshotUrl,
} from "@sportsdataverse/sdvplot";
// ESPN player ids work in every league ESPN_HEADSHOT_LEAGUES lists; nothing is loaded or fetched.
const espn = Object.fromEntries(STANDINGS.slice(0, 3).map((s) => [s.qb, headshotUrl(s.qb_espn_id, "nfl")]));
{
espn,
// Each league's own CDN, by that league's player id.
"LeBron James (nba 2544)": nbaHeadshotUrl(2544),
"A'ja Wilson (wnba 1628932)": wnbaHeadshotUrl(1628932),
"Shohei Ohtani (mlb 660271)": mlbHeadshotUrl(660271),
"Connor McDavid (nhl 8478402)": nhlHeadshotUrl(8478402),
HEADSHOT_ASPECT: HEADSHOT_ASPECT.toFixed(3), // width / height of an ESPN headshot box
ESPN_HEADSHOT_LEAGUES,
};
nflverse gsis ids work too, after loadGsis() imports the id map that ships with the package.
{
"Patrick Mahomes (00-0033873)": "https://static.www.nfl.com/image/upload/t_headshot_desktop/f_auto/league/wdckwtob1lybvkmxnf7p.png",
"Josh Allen (00-0034857)": "https://static.www.nfl.com/image/upload/t_headshot_desktop/f_auto/league/mjwbioajzldkq1vzoz2d.png"
}import { headshotUrl, loadGsis } from "@sportsdataverse/sdvplot";
// The gsis map ships with the package as its own chunk; loadGsis imports it (nothing is downloaded).
await loadGsis();
{
"Patrick Mahomes (00-0033873)": headshotUrl("00-0033873", "nfl", { idSystem: "gsis" }),
"Josh Allen (00-0034857)": headshotUrl("00-0034857", "nfl", { idSystem: "gsis" }),
};
Placements: what every image mark draws
place(xs, ys, teams, { league, kind }) is the step every logo, wordmark and headshot mark is built on: one entry
per drawable row with its position, image URL and aspect ratio. Use it to draw marks in a renderer sdvplot has no
adapter for.
{
"logos": [
{
"id": "12",
"x": 385,
"y": 326,
"aspect": 1,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/3d/3d77958dc6373768919bb2681cbe1b143f56c07a1f013460def665a5026a7f3d.png"
},
{
"id": "24",
"x": 402,
"y": 301,
"aspect": 1,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/54/5400f85bd93129c056717a771da57a97225e15f39c0022107ce89ef993f15bb0.png"
},
{
"id": "7",
"x": 425,
"y": 311,
"aspect": 1,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/c9/c98bec2be32e27b19f79f5da86ac6ef133c78d75ab78aadb28ef36696c3213e8.png"
}
],
"wordmarks": [
{
"id": "12",
"aspect": 3.6458333333333335,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/16/16a2c7e0cd5dcf2e6bdc646c0b22bf03e575f505aa4b843a0ebfdaf19650257b.png"
},
{
"id": "24",
"aspect": 3.6321243523316062,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/ad/ad87ef1e14816a93f9cf06e2f356c526902186abfc4c729e8e01088a84e98cb7.png"
},
{
"id": "7",
"aspect": 3.6510416666666665,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/dd/ddced0c3708afd6977d26e85c6efd4ddf3f045fb4527dd88e940e5952653875d.png"
}
]
}import { STANDINGS } from "@sportsdataverse/examples/data";
import { place, placeSync } from "@sportsdataverse/sdvplot";
const three = STANDINGS.slice(0, 3);
const xs = three.map((s) => s.pf);
const ys = three.map((s) => s.pa);
const ts = three.map((s) => s.team);
// What every logo mark draws: one entry per drawable row (any other renderer can use it too).
const logos = await place(xs, ys, ts, { league: "nfl" });
const wordmarks = placeSync(xs, ys, ts, { league: "nfl", kind: "wordmark" }); // loaded by place()
{
logos: logos.map((p) => ({ id: p.id, x: p.x, y: p.y, aspect: p.aspect, url: p.url })),
wordmarks: wordmarks.map((p) => ({ id: p.id, aspect: p.aspect, url: p.url })),
};
Versions
VERSION is the package's version, INDEX_VERSION the bundled team index's, and versions() adds the date of the
manifest the league data came from. Quote them in a bug report.
{
"VERSION": "0.1.0",
"INDEX_VERSION": "1c025b69f8cf",
"versions": {
"sdvplot": "0.1.0",
"index": "1c025b69f8cf",
"manifestLastModified": "2026-10-06"
},
"@sportsdataverse/sporty VERSION": "0.1.0",
"@sportsdataverse/sdvtables VERSION": "0.1.0"
}import { INDEX_VERSION, VERSION, versions } from "@sportsdataverse/sdvplot";
import { VERSION as SDVTABLES_VERSION } from "@sportsdataverse/sdvtables";
import { VERSION as SPORTY_VERSION } from "@sportsdataverse/sporty";
// Quote these in a bug report: each package's code, the bundled team index and the manifest the shards came from.
{
VERSION,
INDEX_VERSION,
versions: versions(),
"@sportsdataverse/sporty VERSION": SPORTY_VERSION,
"@sportsdataverse/sdvtables VERSION": SDVTABLES_VERSION,
};
In the gallery: Identity, colours, marks.