Skip to main content

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" });
201020192024
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.