Decorations
Decorations are everything around and across the cells: titles and notes, rules and stripes, rows picked out,
legends, a long table folded into blocks. Each is one builder method (sdvplotR's gt_* helpers, renamed), they stack
in any order, and like the rest of a spec they are plain data. All 25 are shown here on the same eight AFC teams.
Titles and notes
title and subtitle caption the table; titleHeader adds a kicker and a date. sourceNote writes a footnote
(escaped unless you mark it unsafe), and caption538 a ruled note and source line in the FiveThirtyEight style.
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.subtitle("Regular season, East and West divisions")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.titleHeader("AFC standings", {
kicker: "NFL 2024",
subtitle: "East and West divisions",
date: "Final, week 18",
})
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | PF | PA |
|---|---|---|
![]() | 385 | 326 |
![]() | 402 | 301 |
![]() | 425 | 311 |
![]() | 309 | 434 |
![]() | 525 | 368 |
![]() | 345 | 364 |
![]() | 338 | 404 |
![]() | 289 | 417 |
| Data: nflverse | ||
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// The note is escaped unless you mark it unsafe (then it is inserted as HTML: only for markup you wrote).
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
])
.title("AFC, 2024")
.sourceNote('Data: <a href="https://nflverse.nflverse.com">nflverse</a>', { unsafe: true })
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
Kansas City lost two games all season. | ||
SOURCE: NFLVERSE | ||
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.caption538({ top: "Kansas City lost two games all season.", bottom: "SOURCE: NFLVERSE" })
.build();
await renderHTMLAsync(spec, STANDINGS);
socialTag adds handles with their icons, watermark faint text (or an image) behind the rows, and font one Google
font for the whole table.
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
Data: nflverse | ||
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.socialTag({ x: "SportsDataverse", gh: "sportsdataverse" }, { caption: "Data: nflverse" })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Qb | Wins | Losses |
|---|---|---|---|
![]() | Patrick Mahomes | 15 | 2 |
![]() | Justin Herbert | 11 | 6 |
![]() | Bo Nix | 10 | 7 |
![]() | Gardner Minshew | 4 | 13 |
![]() | Josh Allen | 13 | 4 |
![]() | Tua Tagovailoa | 8 | 9 |
![]() | Aaron Rodgers | 5 | 12 |
![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// text becomes an inline SVG background; pass image (a URL) instead for a logo.
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("qb"), c.int("wins"), c.int("losses")])
.watermark({ text: "SAMPLE", angle: -20, opacity: 0.08 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Qb | Wins | Losses |
|---|---|---|---|
![]() | Patrick Mahomes | 15 | 2 |
![]() | Justin Herbert | 11 | 6 |
![]() | Bo Nix | 10 | 7 |
![]() | Gardner Minshew | 4 | 13 |
![]() | Josh Allen | 13 | 4 |
![]() | Tua Tagovailoa | 8 | 9 |
![]() | Aaron Rodgers | 5 | 12 |
![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// google: true (the default) adds the Google Fonts <link>; set it false for a font the page already loads.
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("qb"), c.int("wins"), c.int("losses")])
.font("Roboto Slab", { weight: 500 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
Rules, bars and stripes
borderBars draws colour bars above or below the table, borderGrid rules between columns, and cutline a dashed
line after the nth row.
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// Bars only: one bar per colour. With text (or an image): one bar in the first colour holding it.
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.borderBars("top", ["#0B1A33", "#7FE6DC"], { barHeight: 6 })
.borderBars("bottom", ["#0B1A33"], { barHeight: 28, text: "SportsDataverse", textSize: 13 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses | PF |
|---|---|---|---|
![]() | 15 | 2 | 385 |
![]() | 11 | 6 | 402 |
![]() | 10 | 7 | 425 |
![]() | 4 | 13 | 309 |
![]() | 13 | 4 | 525 |
![]() | 8 | 9 | 345 |
![]() | 5 | 12 | 338 |
![]() | 4 | 13 | 289 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("wins"),
c.int("losses"),
c.int("pf", { label: "PF" }),
])
.borderGrid({ color: "#C8C8C8", includeLabels: true })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 13 | 4 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// Sorted by wins, the four playoff teams among these eight sit above the line.
const rows = [...STANDINGS].sort((a, b) => b.wins - a.wins);
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.cutline(4, { label: ["Playoff line"] })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, rows);
Groups
groupBy(key) adds a heading row per group, in the order the rows arrive; groupStripes shades every other group.
| Team | Qb | Wins | Losses |
|---|---|---|---|
| West | |||
![]() | Patrick Mahomes | 15 | 2 |
![]() | Justin Herbert | 11 | 6 |
![]() | Bo Nix | 10 | 7 |
![]() | Gardner Minshew | 4 | 13 |
| East | |||
![]() | Josh Allen | 13 | 4 |
![]() | Tua Tagovailoa | 8 | 9 |
![]() | Aaron Rodgers | 5 | 12 |
![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// Rows are grouped in the order they arrive: sort them first if the groups are interleaved.
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("qb"), c.int("wins"), c.int("losses")])
.groupBy("division")
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Qb | Wins | Losses |
|---|---|---|---|
| West | |||
![]() | Patrick Mahomes | 15 | 2 |
![]() | Justin Herbert | 11 | 6 |
![]() | Bo Nix | 10 | 7 |
![]() | Gardner Minshew | 4 | 13 |
| East | |||
![]() | Josh Allen | 13 | 4 |
![]() | Tua Tagovailoa | 8 | 9 |
![]() | Aaron Rodgers | 5 | 12 |
![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("qb"), c.int("wins"), c.int("losses")])
.groupBy("division")
.groupStripes({ color: "#EEF3FA", start: 1 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
Rows picked out
Each takes a predicate or row indices. boldRows sets rows in bold, spotlight lights one row and dims the rest, and
rowAccent colours each row's edge by a column's value.
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.boldRows({ key: "wins", op: ">=", value: 13 }, { highlightColor: "#FFF3B0" })
.title("AFC, 2024: 13 or more wins")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Qb | Wins | Losses |
|---|---|---|---|
![]() | Patrick Mahomes | 15 | 2 |
![]() | Justin Herbert | 11 | 6 |
![]() | Bo Nix | 10 | 7 |
![]() | Gardner Minshew | 4 | 13 |
![]() | Josh Allen | 13 | 4 |
![]() | Tua Tagovailoa | 8 | 9 |
![]() | Aaron Rodgers | 5 | 12 |
![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("qb"), c.int("wins"), c.int("losses")])
.spotlight({ key: "team", op: "==", value: "BUF" }, { fill: "#E8EEF9", accentColor: "#00338D" })
.title("AFC, 2024: Buffalo")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// A palette maps each division to a colour; hide (the default) drops the division column itself.
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("division"), c.int("wins"), c.int("losses")])
.rowAccent("division", { palette: { East: "#00338D", West: "#E31837" }, width: 6 })
.title("AFC, 2024: East in blue, West in red")
.build();
await renderHTMLAsync(spec, STANDINGS);
Reading the numbers
legendContinuous keys a colour scale (by default the one a colorPills column recorded) and legendDiscrete a set
of named colours. scaleNote divides columns and says so, outliers flags values beyond 1.5 IQR, and significance
adds stars from a p-value column.
| Team | Net EPA/play |
|---|---|
![]() | 0.063 |
![]() | 0.101 |
![]() | 0.108 |
![]() | -0.146 |
![]() | 0.190 |
![]() | -0.019 |
![]() | -0.045 |
![]() |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// With no palette or domain of its own, the legend reads the scale the colorPills column recorded.
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.colorPills("net_epa", { label: "Net EPA/play", digits: 3, domain: [-0.2, 0.2] }),
])
.legendContinuous({ title: "Net EPA per play", digits: 1 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Qb | Week 18 |
|---|---|---|
![]() | Patrick Mahomes | L |
![]() | Justin Herbert | W |
![]() | Bo Nix | W |
![]() | Gardner Minshew | L |
![]() | Josh Allen | L |
![]() | Tua Tagovailoa | L |
![]() | Aaron Rodgers | W |
![]() | Drake Maye | W |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.text("qb"),
c.colorResults("result_last", { label: "Week 18" }),
])
.legendDiscrete({ Won: "#5DA271", Lost: "#C84630" }, { heading: "Final regular-season game" })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | PF / g | PA / g |
|---|---|---|
![]() | 22.6 | 19.2 |
![]() | 23.6 | 17.7 |
![]() | 25.0 | 18.3 |
![]() | 18.2 | 25.5 |
![]() | 30.9 | 21.6 |
![]() | 20.3 | 21.4 |
![]() | 19.9 | 23.8 |
![]() | 17.0 | 24.5 |
| Points per game (17 games). | ||
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// Any divisor works; 1,000 and 1,000,000 come with their own wording ("Figures in thousands.").
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
])
.scaleNote(["pf", "pa"], {
divisor: 17,
decimals: 1,
where: "both",
note: "Points per game (17 games).",
labelSuffix: "/ g",
})
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | PF | PA |
|---|---|---|
![]() | 385 | 326 |
![]() | 402 | 301 |
![]() | 425 | 311 |
![]() | 309 | 434 |
![]() | 525† | 368 |
![]() | 345 | 364 |
![]() | 338 | 404 |
![]() | 289 | 417 |
| Marked values fall outside 1.5 × IQR of the column quartiles. | ||
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// Buffalo's 525 points for sits above the upper fence; nothing in points against does.
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
])
.outliers(["pf", "pa"], { symbol: "†", note: true })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Win % | p vs .500 |
|---|---|---|
![]() | 88.2%*** | 0.002 |
![]() | 64.7% | 0.332 |
![]() | 58.8% | 0.629 |
![]() | 23.5%** | 0.049 |
![]() | 76.5%** | 0.049 |
![]() | 47.1% | 1.000 |
![]() | 29.4% | 0.143 |
![]() | 23.5%** | 0.049 |
| *** p < 0.01, ** p < 0.05, * p < 0.1 | ||
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// p: an exact two-sided binomial test of each record against a coin flip (no ties in these rows).
const choose = (n: number, k: number): number => (k === 0 ? 1 : (choose(n, k - 1) * (n - k + 1)) / k);
function pVs500(w: number, l: number): number {
let tail = 0;
for (let k = Math.max(w, l); k <= w + l; k++) tail += choose(w + l, k) / 2 ** (w + l);
return Math.min(1, 2 * tail);
}
const rows = STANDINGS.map((r) => ({
...r,
win_share: r.wins / (r.wins + r.losses),
p: pVs500(r.wins, r.losses),
}));
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.pct("win_share", { label: "Win %" }),
c.num("p", { label: "p vs .500", digits: 3 }),
])
.significance([{ estimate: "win_share", p: "p" }], { hideP: false })
.title("8 AFC teams, 2024: which records beat a coin flip?")
.build();
await renderHTMLAsync(spec, rows);
Layout
wrapLabels wraps long column labels, marginalia sets a column as side notes, and tiers turns rows into a tier
list with one row per tier.
| Team | Points scored, regular season | Points allowed, regular season |
|---|---|---|
![]() | 385 | 326 |
![]() | 402 | 301 |
![]() | 425 | 311 |
![]() | 309 | 434 |
![]() | 525 | 368 |
![]() | 345 | 364 |
![]() | 338 | 404 |
![]() | 289 | 417 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("pf", { label: "Points scored, regular season" }),
c.int("pa", { label: "Points allowed, regular season" }),
])
.wrapLabels({ width: 10 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Wins | Losses | Starting QB |
|---|---|---|---|
![]() | 15 | 2 | Patrick Mahomes |
![]() | 11 | 6 | Justin Herbert |
![]() | 10 | 7 | Bo Nix |
![]() | 4 | 13 | Gardner Minshew |
![]() | 13 | 4 | Josh Allen |
![]() | 8 | 9 | Tua Tagovailoa |
![]() | 5 | 12 | Aaron Rodgers |
![]() | 4 | 13 | Drake Maye |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses"), c.text("qb")])
.marginalia(["qb"], { label: "Starting QB", width: 160 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
| SRS top 10 | ![]() | ![]() | ![]() | ![]() |
| 11th to 20th | ||||
| 21st to 32nd | ![]() | ![]() | ![]() | ![]() |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague, logoUrlSync } from "@sportsdataverse/sdvplot";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// One row per tier: its label, then a logo URL per team (league-wide SRS rank) in the image columns.
await loadLeague("nfl");
const levels = ["SRS top 10", "11th to 20th", "21st to 32nd"];
const tierOf = (rank: number): string | undefined => levels[rank <= 10 ? 0 : rank <= 20 ? 1 : 2];
const rows = levels.map((tier) => {
const logos = STANDINGS.filter((r) => tierOf(r.srs_rank) === tier).map(
(r) => logoUrlSync(r.team, "nfl") ?? null,
);
// four image columns: a fifth team in a tier would be dropped without a word
if (logos.length > 4) throw new Error(`${tier} has ${logos.length} teams but 4 image columns`);
return { tier, t1: logos[0] ?? null, t2: logos[1] ?? null, t3: logos[2] ?? null, t4: logos[3] ?? null };
});
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [c.text("tier"), c.text("t1"), c.text("t2"), c.text("t3"), c.text("t4")])
.tiers(levels, "tier", ["t1", "t2", "t3", "t4"], {
colors: ["#FF7F7F", "#FFDF7F", "#BFBFBF"],
imgHeight: "44px",
style: "light",
})
.title("8 AFC teams, 2024: SRS tiers")
.build();
await renderHTMLAsync(spec, rows);
snake folds a long table into side-by-side blocks; snakeAlign is the split it uses, for laying out blocks
yourself.
| Team | Wins | Losses | Team | Wins | Losses | |
|---|---|---|---|---|---|---|
![]() | 15 | 2 | ![]() | 13 | 4 | |
![]() | 11 | 6 | ![]() | 8 | 9 | |
![]() | 10 | 7 | ![]() | 5 | 12 | |
![]() | 4 | 13 | ![]() | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.snake({ nCols: 2 })
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
[
[
"KC",
"LAC",
"DEN"
],
[
"LV",
"BUF",
"MIA"
],
[
"NYJ",
"NE",
"(fill)"
]
]import { STANDINGS } from "@sportsdataverse/examples/data";
import { snakeAlign } from "@sportsdataverse/sdvtables";
// Eight rows into three blocks of three; the last block is padded with fill (null unless you pass one).
snakeAlign(STANDINGS, { nCols: 3 }).map((block) => block.map((r) => r?.team ?? "(fill)"));
In the gallery: Every decoration and Layout.







