Skip to main content

Tables: the spec

@sportsdataverse/sdvtables is the table half of sdvplot-js: the formatting of sdvplotR's gt helpers and gtUtils (logos in cells, colour scales, tiers, themes) as a plain-data table spec and an HTML renderer. A spec is built once, checked against your row type and validated when built, then rendered anywhere: on a server, in a static build or in the browser. This guide is the spec; the guides after it cover themes, cell kinds, decorations and rendering. The sample rows are on the Sample data page.

A spec is plain data​

defineTable<Row>() starts a builder; .columns, .theme, .title and the decorations each return a new builder, and .build() validates once and returns a plain object that JSON can carry (server to browser, a file, a cache key).

{
  "columns": [
    {
      "kind": "logo",
      "key": "team",
      "height": 30,
      "includeName": false,
      "league": "nfl"
    },
    {
      "kind": "int",
      "key": "wins"
    },
    {
      "kind": "int",
      "key": "losses"
    },
    {
      "kind": "colorPills",
      "key": "net_epa",
      "palette": [
        "#C84630",
        "#5DA271"
      ],
      "fillType": "continuous",
      "rankOrder": "desc",
      "formatType": "number",
      "scalePercent": true,
      "suffix": "",
      "reverse": false,
      "outlineWidth": 0.25,
      "pillHeight": 25,
      "label": "Net EPA/play",
      "digits": 3,
      "domain": [
        -0.2,
        0.2
      ]
    }
  ],
  "decorations": [
    {
      "type": "title",
      "text": "AFC, 2024"
    }
  ],
  "theme": {
    "name": "midnight",
    "density": "comfortable"
  }
}
import type { Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";

// defineTable<Row>() checks every key against the row type; build() validates once and returns a plain object
// that JSON can carry (server to browser, a file, a cache key). Rendering is a separate step.
defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("wins"),
c.int("losses"),
c.colorPills("net_epa", { label: "Net EPA/play", digits: 3, domain: [-0.2, 0.2] }),
])
.theme("midnight")
.title("AFC, 2024")
.build();

Rendering it​

renderHTML(spec, rows) returns the table as an HTML string. It is synchronous, so first await prepare(spec), which loads the league data the spec's logo and team-colour columns need.

AFC, 2024
TeamWinsLosses
KC152
BUF134
LAC116
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";

const rows = [
{ team: "KC", wins: 15, losses: 2 },
{ team: "BUF", wins: 13, losses: 4 },
{ team: "LAC", wins: 11, losses: 6 },
];
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [c.text("team"), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
renderHTML(spec, rows);
TeamPoints forPoints against
BUF525368
DEN425311
LAC402301
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";

const rows = [
{ team: "BUF", pf: 525, pa: 368 },
{ team: "DEN", pf: 425, pa: 311 },
{ team: "LAC", pf: 402, pa: 301 },
];
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [c.text("team"), c.int("pf", { label: "Points for" }), c.int("pa", { label: "Points against" })])
.theme("athletic")
.build();
renderHTML(spec, rows);
AFC, 2024
TeamWinsLosses
152
116
107
413
134
89
512
413
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, renderHTML } from "@sportsdataverse/sdvtables/html";

export const spec = defineTable<(typeof STANDINGS)[number]>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
await prepare(spec); // loads the NFL shard renderHTML needs; renderHTML itself is synchronous
renderHTML(spec, STANDINGS);

The column factory and the builder​

.columns((c) => [...]) hands you the column factory, the same object columnFactory<Row>() returns: each c.* call is one column with sdvplotR's defaults, and the gtUtils names are aliases (c.fmtRank is c.rank). RANK_PALETTE is the palette rank colours use.

{
  "c.rank(\"srs_rank\")": {
    "kind": "rank",
    "key": "srs_rank",
    "superscript": true,
    "suffixSize": "0.7em"
  },
  "c.delta(\"pa\", \"pf\")": {
    "kind": "delta",
    "key": "pa",
    "to": "pf",
    "label": "Change",
    "percent": false,
    "decimals": 1,
    "arrows": false,
    "color": true,
    "colorPositive": "#1B7837",
    "colorNegative": "#B2182B",
    "forceSign": true
  },
  "c.colorRanks(\"pf\").palette is RANK_PALETTE": true,
  "RANK_PALETTE": [
    "#3D8B6E",
    "#9DC5A7",
    "#EDE0CC",
    "#DB9070",
    "#BE4D3A"
  ],
  "c.fmtRank is c.rank (the gtUtils name)": true,
  "defineTable() is a TableBuilder": true,
  "every method returns a new builder": true
}
import type { Standing } from "@sportsdataverse/examples/data";
import { RANK_PALETTE, TableBuilder, columnFactory, defineTable } from "@sportsdataverse/sdvtables";

// .columns((c) => [...]) hands you this factory; each c.* call returns a column spec with the sdvplotR defaults.
const c = columnFactory<Standing>();
const base = defineTable<Standing>();
const titled = base.title("AFC, 2024");

{
'c.rank("srs_rank")': c.rank("srs_rank"),
'c.delta("pa", "pf")': c.delta("pa", "pf"),
'c.colorRanks("pf").palette is RANK_PALETTE': c.colorRanks("pf").palette === RANK_PALETTE,
RANK_PALETTE,
"c.fmtRank is c.rank (the gtUtils name)": c.fmtRank === c.rank,
"defineTable() is a TableBuilder": base instanceof TableBuilder,
"every method returns a new builder": titled !== base,
};

Rows picked by data​

Decorations that pick rows (highlight, boldRows, spotlight, rowAccent) take a predicate such as { key: "wins", op: ">=", value: 11 }, or row indices, so a spec stays JSON. selectRows and matches evaluate them.

{
  "wins >= 11": [
    "KC",
    "LAC",
    "BUF"
  ],
  "division == \"East\"": [
    "BUF",
    "MIA",
    "NYJ",
    "NE"
  ],
  "net_epa isNull": [
    "NE"
  ],
  "team in [\"KC\", \"BUF\"]": [
    "KC",
    "BUF"
  ],
  "qb matches \"^J\"": [
    "LAC",
    "BUF"
  ],
  "selectRows([0, 4]) (indices pass through)": [
    0,
    4
  ],
  "matches(wins > 14, KC)": true
}
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { type Predicate, matches, selectRows } from "@sportsdataverse/sdvtables";

// highlight, boldRows, spotlight and rowAccent take a Predicate (or row indices): plain data, so a spec stays JSON.
const teams = (p: Predicate<Standing>): string[] =>
selectRows(p, STANDINGS).map((i) => STANDINGS[i]?.team ?? "");
const [kc] = STANDINGS;

{
"wins >= 11": teams({ key: "wins", op: ">=", value: 11 }),
'division == "East"': teams({ key: "division", op: "==", value: "East" }),
"net_epa isNull": teams({ key: "net_epa", op: "isNull" }),
'team in ["KC", "BUF"]': teams({ key: "team", op: "in", value: ["KC", "BUF"] }),
'qb matches "^J"': teams({ key: "qb", op: "matches", value: "^J" }),
"selectRows([0, 4]) (indices pass through)": selectRows([0, 4], STANDINGS),
"matches(wins > 14, KC)": kc !== undefined && matches({ key: "wins", op: ">", value: 14 }, kc),
};

Errors, raised early​

A spec that cannot render throws one TableSpecError (an SdvplotError) when it is built or rendered, naming the problem: no columns, a key used twice, a column the rows do not have.

{
  "build() with no columns": "TableSpecError: a table needs at least one column: call .columns(c => [...])",
  "the same key twice": "TableSpecError: column key \"wins\" appears twice",
  "two titles": "TableSpecError: only one .title()",
  "an unknown theme": "TableSpecError: unknown theme \"neon\"; one of sdv, sdvTeam, almanac, athletic, booktabs, broadsheet, brutalist, drench, gtutils, kenpom, midnight, ncaa, pl, savant, scoreboard, sofa, swiss, terminal, tier, tufte",
  "sdvTeam without a league": "TableSpecError: sdvTeam needs options.league (e.g. { league: \"nfl\", team: \"KC\" })",
  "a column the rows lack": "TableSpecError: column \"net_epa\" (kind text) is not a key of the first row; keys are team, conf, division, wins, losses, ties, pf, pa, srs_rank, qb, qb_espn_id, result_last"
}
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { SdvplotError } from "@sportsdataverse/sdvplot";
import { TableSpecError, defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";

const caught = (f: () => unknown): string => {
try {
f();
return "no error";
} catch (e) {
if (!(e instanceof TableSpecError && e instanceof SdvplotError)) throw e;
return `${e.name}: ${e.message}`;
}
};
const t = defineTable<Standing>();
const wins = t.columns((c) => [c.int("wins")]);
// Rows read from a file are untyped; here a CSV that lost its net_epa column.
const csv: Record<string, unknown>[] = STANDINGS.map(({ net_epa: _dropped, ...rest }) => rest);
const loose = defineTable<Record<string, unknown>>();

{
"build() with no columns": caught(() => t.build()),
"the same key twice": caught(() => t.columns((c) => [c.int("wins"), c.int("wins")]).build()),
"two titles": caught(() => wins.title("a").title("b").build()),
"an unknown theme": caught(() => renderHTML(wins.theme("neon").build(), STANDINGS)),
"sdvTeam without a league": caught(() => renderHTML(wins.theme("sdvTeam").build(), STANDINGS)),
"a column the rows lack": caught(() =>
renderHTML(loose.columns((c) => [c.text("team"), c.text("net_epa")]).build(), csv),
),
};

In the gallery: Table spec.