@sportsdataverse/sdvtables 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SportsDataverse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,4 @@
1
+ # Notices
2
+
3
+ - `@sportsdataverse/sdvtables` ports the `great_tables` helpers of **sdvplot** (Python, MIT) and the `gt_*` table functions of **sdvplotR** (MIT). Team logos, headshots and colors come from `@sportsdataverse/sdvplot`.
4
+ - The social icons in `src/html/social-icons.ts` are Font Awesome Free 6 paths (https://fontawesome.com), licensed CC BY 4.0.
package/README.md CHANGED
@@ -1,3 +1,520 @@
1
- # Temporary Holding Version
1
+ # **@sportsdataverse/sdvtables** <a href='https://plot.sportsdataverse.org/'><img src='https://raw.githubusercontent.com/sportsdataverse/sdvplot-js/main/docs/static/img/sdvplot-js-logo.png' align="right" width="25%" min-width="120px" alt="sdvplot-js hex logo" /></a>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <!-- badges: start -->
4
+
5
+ [![npm](https://img.shields.io/npm/v/@sportsdataverse/sdvtables?label=sdvtables&logo=npm&style=for-the-badge)](https://www.npmjs.com/package/@sportsdataverse/sdvtables)
6
+ [![Downloads](https://img.shields.io/npm/dm/@sportsdataverse/sdvtables?style=for-the-badge)](https://www.npmjs.com/package/@sportsdataverse/sdvtables)
7
+ [![Total downloads](https://img.shields.io/npm/dt/@sportsdataverse/sdvtables?style=for-the-badge)](https://www.npmjs.com/package/@sportsdataverse/sdvtables)
8
+ [![Node](https://img.shields.io/node/v/@sportsdataverse/sdvtables?logo=nodedotjs&logoColor=white&style=for-the-badge)](https://nodejs.org/)
9
+ [![Unpacked size](https://img.shields.io/npm/unpacked-size/@sportsdataverse/sdvtables?style=for-the-badge)](https://www.npmjs.com/package/@sportsdataverse/sdvtables?activeTab=code)
10
+ [![ci](https://img.shields.io/github/actions/workflow/status/sportsdataverse/sdvplot-js/ci.yml?branch=main&label=ci&logo=github&style=for-the-badge)](https://github.com/sportsdataverse/sdvplot-js/actions/workflows/ci.yml)
11
+ [![release](https://img.shields.io/github/actions/workflow/status/sportsdataverse/sdvplot-js/release.yml?branch=main&label=release&logo=github&style=for-the-badge)](https://github.com/sportsdataverse/sdvplot-js/actions/workflows/release.yml)
12
+ [![docs](https://img.shields.io/github/deployments/sportsdataverse/sdvplot-js/Production?label=docs&logo=vercel&style=for-the-badge)](https://plot.sportsdataverse.org)
13
+ [![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg?style=for-the-badge&logo=github)](https://lifecycle.r-lib.org/articles/stages.html#experimental)
14
+ [![License](https://img.shields.io/github/license/sportsdataverse/sdvplot-js?style=for-the-badge)](https://github.com/sportsdataverse/sdvplot-js/blob/main/LICENSE)
15
+ [![Contributors](https://img.shields.io/github/contributors/sportsdataverse/sdvplot-js?style=for-the-badge)](https://github.com/sportsdataverse/sdvplot-js/graphs/contributors)
16
+ [![Twitter Follow](https://img.shields.io/twitter/follow/SportsDataverse?color=blue&label=%40SportsDataverse&logo=x&style=for-the-badge)](https://x.com/SportsDataverse)
17
+
18
+ <!-- badges: end -->
19
+
20
+ Serializable table specs rendered to HTML (static, or interactive with a headless engine) and to PNG, with
21
+ SportsDataverse team identity and themes. A `TableSpec` is plain
22
+ data (JSON-safe), so the same spec renders in Node, the browser and notebooks. Zero runtime dependencies beyond
23
+ [`@sportsdataverse/sdvplot`](https://plot.sportsdataverse.org) (team logos, headshots and colors).
24
+
25
+ Siblings: [sdvplot](https://sdvplot.sportsdataverse.org/) (Python, `great_tables` helpers) and
26
+ [sdvplotR](https://sdvplotR.sportsdataverse.org/) (R, `gt_*`). This package ports both; the name map is
27
+ [below](#gtutils--sdvplotr-names).
28
+
29
+ Part of [sdvplot-js](https://github.com/sportsdataverse/sdvplot-js#readme) · [Documentation](https://plot.sportsdataverse.org) · [API reference](https://plot.sportsdataverse.org/api/sdvtables/) ·
30
+ [Gallery](https://plot.sportsdataverse.org/gallery/sdvtables/) · [Notebooks](https://plot.sportsdataverse.org/notebooks/) · [Source](https://github.com/sportsdataverse/sdvplot-js/tree/main/packages/sdvtables)
31
+
32
+ ## **Installation**
33
+
34
+ ```sh
35
+ npm install @sportsdataverse/sdvtables @sportsdataverse/sdvplot
36
+ # or
37
+ pnpm add @sportsdataverse/sdvtables @sportsdataverse/sdvplot
38
+ # or
39
+ yarn add @sportsdataverse/sdvtables @sportsdataverse/sdvplot
40
+ ```
41
+
42
+ ESM only, Node >= 20.18.1. `@sportsdataverse/sdvplot` is a required peer (npm 7+ and pnpm install it for you).
43
+
44
+ ## **Quick start**
45
+
46
+ ```ts
47
+ import { defineTable } from "@sportsdataverse/sdvtables";
48
+ import { prepare, renderHTML, toElement } from "@sportsdataverse/sdvtables/html";
49
+
50
+ const spec = defineTable<{ team: string; wins: number }>()
51
+ .columns((c) => [c.logo("team", { league: "nfl", includeName: true }), c.int("wins")])
52
+ .theme("midnight")
53
+ .title("AFC West")
54
+ .build();
55
+ const rows = [
56
+ { team: "KC", wins: 15 },
57
+ { team: "LAC", wins: 11 },
58
+ { team: "DEN", wins: 10 },
59
+ { team: "LV", wins: 4 },
60
+ ]; // the 2024 AFC West
61
+
62
+ await prepare(spec); // loads leagues, team names and gsis ids the spec needs
63
+ const html = renderHTML(spec, rows); // string (Node, SSR, notebooks)
64
+ document.body.append(toElement(spec, rows)); // browser; Observable: html`${renderHTML(spec, rows)}`
65
+ ```
66
+
67
+ `prepare(spec)` must be awaited before `renderHTML`. `renderHTMLAsync` does both.
68
+
69
+ ## **Hosts and CSS**
70
+
71
+ Every themed value is a `--sdvt-*` custom property on the table wrapper, and each element carries at most one
72
+ `style` attribute. By default `renderHTML` inlines a scoped `<style>` block. A page that renders many tables should
73
+ emit the stylesheet once per theme and render the tables with `css: "none"`:
74
+
75
+ ```ts
76
+ import { renderHTML, styleSheet, themeKey } from "@sportsdataverse/sdvtables/html";
77
+ const seen = new Set<string>();
78
+ const parts = specs.map((spec) => {
79
+ const key = themeKey(spec.theme);
80
+ const sheet = seen.has(key) ? "" : `<style>${styleSheet(spec)}</style>`; // once per themeKey
81
+ seen.add(key);
82
+ return sheet + renderHTML(spec, rows, { css: "none" });
83
+ });
84
+ ```
85
+
86
+ `css: "none"` drops only the shared theme sheet. Each table still carries its own `#id`-scoped decoration `<style>`
87
+ and the Google Fonts `<link>` for its theme and `.font()` faces; pass `fonts: false` to drop the link and load the
88
+ fonts yourself (`fontsLink(fonts)` builds the same tag). `toElement` moves the link into `document.head`, once per URL.
89
+
90
+ Decoration CSS is scoped to the table `#id`. Two renders of the same spec on one page therefore need distinct ids:
91
+ call `.id("a")` / `.id("b")` on the builder.
92
+
93
+ ## **Themes**
94
+
95
+ 20 themes: the 18 gtUtils themes plus `sdv` (light/dark) and `sdvTeam`. Set one with
96
+ `.theme(name, { density, options })`.
97
+
98
+ | Theme | Options | Default density |
99
+ |---|---|---|
100
+ | `sdv` | `style` (`light`/`dark`) | comfortable |
101
+ | `sdvTeam` | `league` (required), `team` | comfortable |
102
+ | `almanac` | `accent`, `stripe` (`"none"` for no banding) | compact |
103
+ | `broadsheet` | `accent`, `paper` | comfortable |
104
+ | `drench` | `color` | comfortable |
105
+ | `sofa`, `tier` | `style` | comfortable |
106
+ | `scoreboard`, `terminal` | `accent` | compact |
107
+ | `booktabs`, `brutalist`, `midnight`, `swiss`, `tufte` | `accent` | comfortable |
108
+ | `athletic`, `gtutils`, `kenpom`, `ncaa`, `pl`, `savant` | none | comfortable |
109
+
110
+ | Density | body | padding | title | subtitle | label | group | source |
111
+ |---|---|---|---|---|---|---|---|
112
+ | `comfortable` | 14 | 6 | 26 | 15 | 10 | 11 | 11 |
113
+ | `compact` | 12 | 3 | 22 | 13 | 9 | 10 | 10 |
114
+ | `social` | 17 | 9 | 34 | 19 | 12 | 13 | 13 |
115
+
116
+ `themePreview(spec, rows, themes?, { n, density })` from `@sportsdataverse/sdvtables/html` returns
117
+ `Record<themeName, html>` (first `n` rows, compact).
118
+
119
+ ## **Surface**
120
+
121
+ - 29 column kinds on the `c` factory (`text`, `num`, `int`, `pct`, `rank`, `delta`, `tally`, `logo`, `wordmark`,
122
+ `headshot`, `colorPills`, `colorRanks`, `colorResults`, `percentileBar`, `indicatorBox`, `highlight`,
123
+ `highlightNa`, `mergeStackTeamColor`, `teamColorBar`, `teamColorBg`, `image`, and the reactablefmtr formatters
124
+ `dataBar`, `iconSet`, `iconAssign`, `mergeStack`, `bubble`, and the in-cell charts `sparkline`, `sparkbar`, `gauge`
125
+ (inline SVG, array-valued columns for the first two); `fmtRank`/`fmtTally` alias `rank`/`tally`).
126
+ Any column takes `tooltip: true` (the value as a `title` attribute).
127
+ - 25 table decorations as builder methods (titles, legends, cutlines, row accents, snake layout, tiers, watermark, ...).
128
+ - Conditions are data predicates (`{ key, op, value }`), not callbacks, so specs stay serializable.
129
+ - Number cells display negatives with U+2212 (a true minus); `formatValue`-style labels (legends, notes) stay ASCII.
130
+ - Unknown team ids warn once per call per set and render as plain text.
131
+
132
+ ## **Interactive**
133
+
134
+ `createTable(spec, rows, options)` is the package's own headless engine (no table library): sort, per-column and
135
+ global text filters, paging, column visibility and row selection, with immutable snapshots and `subscribe`. Every
136
+ column sorts unless `sortable: false`, missing values sort last in both directions, and a column's `compare(a, b)`
137
+ replaces the kind's comparator. `filterable: true` gives a column its own filter box; the search box searches every
138
+ shown column. The page size is `options.pageSize`, else `spec.interactive.pageSize`, else one page.
139
+
140
+ ```ts
141
+ import { createTable, defineTable } from "@sportsdataverse/sdvtables";
142
+ import { hydrate, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
143
+
144
+ // 2024 AFC regular season (nflverse games)
145
+ const rows = [
146
+ { team: "KC", division: "West", wins: 15, losses: 2, pf: 385, pa: 326 },
147
+ { team: "LAC", division: "West", wins: 11, losses: 6, pf: 402, pa: 301 },
148
+ { team: "DEN", division: "West", wins: 10, losses: 7, pf: 425, pa: 311 },
149
+ { team: "LV", division: "West", wins: 4, losses: 13, pf: 309, pa: 434 },
150
+ { team: "BUF", division: "East", wins: 13, losses: 4, pf: 525, pa: 368 },
151
+ { team: "MIA", division: "East", wins: 8, losses: 9, pf: 345, pa: 364 },
152
+ { team: "NYJ", division: "East", wins: 5, losses: 12, pf: 338, pa: 404 },
153
+ { team: "NE", division: "East", wins: 4, losses: 13, pf: 289, pa: 417 },
154
+ ];
155
+ type Row = (typeof rows)[number];
156
+ const spec = defineTable<Row>()
157
+ .columns((c) => [
158
+ c.logo("team", { league: "nfl", includeName: true }),
159
+ c.text("division", { filterable: true }),
160
+ c.int("wins"),
161
+ c.int("losses"),
162
+ c.int("pf", { label: "PF" }),
163
+ c.int("pa", { label: "PA" }),
164
+ ])
165
+ .title("AFC")
166
+ .subtitle("2024 regular season")
167
+ .rowKey("team")
168
+ .build();
169
+
170
+ await prepare(spec);
171
+ const table = createTable(spec, rows, { pageSize: 4, sort: { col: "wins", dir: "desc" } });
172
+ const host = document.querySelector("#standings")!;
173
+ host.innerHTML = renderHTML(table); // the same string renders on a server or a static site
174
+ const stop = hydrate(host.querySelector(".sdvt")!, table); // sort, filter, page and select without React
175
+ table.setFilter("division", "east"); // BUF, MIA, NYJ, NE; back to the first page
176
+ ```
177
+
178
+ `renderHTML(table)` draws sort buttons in the headers (`aria-sort` on the sorted one), a search box and the filter
179
+ boxes above the table, and a pager below it. `hydrate(el, table)` attaches delegated listeners and re-renders only the
180
+ table block and the pager, once, on the next animation frame, however many changes land in it; it returns a teardown.
181
+ A click, pointer move or key that arrives while a change to the rows (a sort, filter, page turn or `setRows`) waits for
182
+ that frame draws it first, so the event never acts on an old row at a new row's index; Enter or Space on a row that
183
+ draw replaced is consumed, so the page does not scroll. A change that keeps the rows (a selection, or a live feed
184
+ re-sending a keyed row as a new object) waits for its frame. Render the markup from the same table state you hydrate:
185
+ attaching does not reconcile the two. Hydrating an element again draws any change its previous binding still owed. A
186
+ teardown drops a render still owed; hydrating that element again redraws it first, so React StrictMode's mount,
187
+ cleanup, mount leaves no stale rows.
188
+
189
+ Inside an interactive table: j/k (↓/↑) move between the rows of the page and never turn it (the pager does), h/l
190
+ (←/→) pick a column, s sorts it, / jumps to the search box, Enter or Space toggles the row's selection;
191
+ `interactive: { hotkeys: false }` on the spec keeps only the arrows, Enter and Space. The table is a `role="grid"`
192
+ whose rows share one tab stop, the current row; the current column's header carries
193
+ `aria-current="true"` and a selected row `aria-selected="true"`. Keys pressed with Ctrl, Meta or Alt, during IME
194
+ composition, or in a text field are left alone. Clicking a row toggles its selection too.
195
+
196
+ In an interactive table, colour scales, legends, outlier limits and row-accent palettes are computed from all of the
197
+ table's rows, not the page on screen, so a value keeps its colour while the user pages, filters or sorts. Row
198
+ selectors by index (`.boldRows([0])`) count in the rows passed to `createTable`. `snake` layouts are static only: an
199
+ interactive render of a `.snake()` spec throws `TableSpecError`.
200
+
201
+ Linking a table to a figure: with `.rowKey("team")`, a row's id is `String(row.team)` (without one, its index in the
202
+ input rows). `setSelection(ids)` / `getSelection()`, `setExternalFilter((row) => …)` (ANDed with the table's own
203
+ filters) and `setHover(id)` drive the table from outside, and `subscribe` reports `select`, `hover` and `change`
204
+ events. Setting the same selection or filter again notifies nobody, so a two-way link does not loop.
205
+ `@sportsdataverse/sdvplot/interact`'s `linkSelection` does this wiring against a selection store.
206
+
207
+ Every interactive table, linked or not, underlines the row under the pointer, or the row a linked figure hovers
208
+ (`setHover`): the row gets the `sdvt-hover` class, which re-renders nothing and survives the next re-render. A table
209
+ holds one hover id, so a linked table lights the first id the figure hovers. `getHover()` returns it, so a view
210
+ attached later (a remount, a second hydrate) shows it. Unmounting while the pointer is on a row leaves that hover in
211
+ place, because a removed row fires no `mouseleave`. reactable's `highlight` defaults to `FALSE`; to drop the underline,
212
+ add `tr.sdvt-hover>td.sdvt-cell{background-image:none}` to the page. The built-in rule is wrapped in `:where()`, so
213
+ this one plain rule wins wherever the page puts it. A selection or brush from a linked figure
214
+ rebuilds the table body, so a scroll position inside a cell resets.
215
+
216
+ Engine non-goals: virtualization, column resize/reorder/pin, a grouping UI and server-side paging (slice the rows
217
+ before `createTable`).
218
+
219
+ ### React
220
+
221
+ ```tsx
222
+ import { fontsLinkFor } from "@sportsdataverse/sdvtables/html";
223
+ import { SdvTable, useTable } from "@sportsdataverse/sdvtables/react"; // peer: react >= 18
224
+
225
+ // spec and rows from above; await prepare(spec) before the first render, server included
226
+ <SdvTable spec={spec} rows={rows} interactive pageSize={4} />; // SSR markup === renderHTML(table, { fonts: false })
227
+
228
+ function Standings() {
229
+ const { table, snapshot } = useTable(spec, rows, { pageSize: 4 });
230
+ return (
231
+ <>
232
+ <p>{snapshot.filteredCount} teams</p>
233
+ <SdvTable table={table} interactive />
234
+ </>
235
+ );
236
+ }
237
+ ```
238
+
239
+ `<SdvTable/>` writes no fonts `<link>`: put the tag `fontsLinkFor(spec)` returns in the page `<head>`. `pageSize` and
240
+ `sort` are initial state, like React's `default*` props. A new `rows` array, even an equal copy, keeps the user's
241
+ sort, filters, page and selection; only a structural `spec` change builds a new engine. Without `interactive` the
242
+ component renders the static table. The `react` entry is marked `"use client"`.
243
+
244
+ ### MUI X Data Grid
245
+
246
+ ```tsx
247
+ import { DataGrid } from "@mui/x-data-grid"; // peer: @mui/x-data-grid >= 9.15.0 (MIT; never -pro or -premium)
248
+ import { prepare } from "@sportsdataverse/sdvtables/html";
249
+ import { dataGridProps } from "@sportsdataverse/sdvtables/mui";
250
+
251
+ await prepare(spec);
252
+ <DataGrid rows={rows} {...dataGridProps(spec, rows, { teamRows: { key: "team", league: "nfl" } })} autoHeight />;
253
+ ```
254
+
255
+ `dataGridProps(spec, rows, o)` maps a spec onto MUI X Data Grid: one `GridColDef` per column (label, alignment, px
256
+ width, sdvtables' number formats through `valueFormatter`), logo, wordmark and headshot cells through `renderCell`,
257
+ `teamColorBg` / `teamColorBar` cells and `teamRows` in sdvplot's team palette (`@sportsdataverse/sdvplot/mui`'s
258
+ `teamColorPalette`) with black or white text by contrast, and the theme's fonts, colours, stripes and density as the
259
+ grid's `sx` and `density`. It never imports MUI. Kinds drawn in HTML or SVG (pills, bars, sparklines...) show their
260
+ plain value, with one warning per kind. Guide: [MUI X Data Grid](https://plot.sportsdataverse.org/guides/mui-x-data-grid).
261
+
262
+ ### AG Grid
263
+
264
+ ```ts
265
+ import { AllCommunityModule, ModuleRegistry, createGrid, themeQuartz } from "ag-grid-community"; // peer: >= 36.2.0 (MIT)
266
+ import { gridOptions } from "@sportsdataverse/sdvtables/aggrid";
267
+ import { prepare } from "@sportsdataverse/sdvtables/html";
268
+
269
+ ModuleRegistry.registerModules([AllCommunityModule]);
270
+ await prepare(spec);
271
+ const el = document.querySelector("#grid")!; // AG Grid fills its container: size it, or use domLayout: "autoHeight"
272
+ createGrid(el, {
273
+ ...gridOptions(spec, rows, { theme: themeQuartz, teamRows: { key: "team", league: "nfl" } }),
274
+ domLayout: "autoHeight",
275
+ });
276
+ ```
277
+
278
+ `gridOptions(spec, rows, o)` maps a spec onto AG Grid Community: one column definition per column (label, alignment,
279
+ px width, sdvtables' number formats through `valueFormatter`, sdvtables' sort with missing values last both ways),
280
+ logo, wordmark and headshot cells through cell renderer classes that work in plain JavaScript and in ag-grid-react,
281
+ `teamColorBg` / `teamColorBar` cells and `teamRows` in sdvplot's team palette with black or white text by contrast
282
+ (kept through AG Grid's hover and selection), unique row ids, and, given AG Grid's `themeQuartz` (or another base), the
283
+ theme's fonts and colours through AG Grid's Theming API. It never imports AG Grid at runtime. Community only: nothing
284
+ here uses or needs `ag-grid-enterprise`. Guide: [AG Grid](https://plot.sportsdataverse.org/guides/ag-grid).
285
+
286
+ ### TanStack Table v9
287
+
288
+ `@sportsdataverse/sdvtables/tanstack-table` turns a spec into TanStack Table v9 options:
289
+ `tanstackTable(spec, rows, { features, render })` returns `options` for React's `useTable` or Svelte's `createTable`
290
+ (`features` is your own `tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel() })`, passed through)
291
+ and `columnStyle`, each column's alignment and width. Columns keep the HTML table's labels and number text (a
292
+ `scaleNote` and ordinals included), sort with sdvtables' comparator with missing values last both ways, and take their
293
+ row ids from the spec's `rowKey` (a repeated or blank value throws). Logo, wordmark, headshot and superscript-rank cells
294
+ come to your `render` as plain data (`CellPart`), which you turn into a React element or a Svelte component; a team or
295
+ player with no image keeps its text and warns once. It imports table-core's types only and touches no DOM, so it runs on
296
+ a server. Peer: `@tanstack/table-core` >= 9.3.1, optional (its types are in the declarations, so TypeScript needs it
297
+ installed). Call `await prepare(spec)` first.
298
+
299
+ The guide, [TanStack Table](https://plot.sportsdataverse.org/guides/tanstack-table), shows a sortable React component
300
+ and a Svelte 5 one (`examples/snippets/tanstack-table/SdvTable.tsx` and `examples/snippets/svelte/TanstackStandings.svelte`
301
+ in this repository), both compiled and rendered by the tests.
302
+
303
+ **From the v8 recipe.** This README used to carry an untested recipe for `@tanstack/react-table` 8 (`toTanStackColumns`,
304
+ reading each cell's markup back from `toElement()`). TanStack Table v9 changed its API: `useTable` replaces
305
+ `useReactTable`, features and row models are registered in `tableFeatures` (`createSortedRowModel()` where v8 took
306
+ `getSortedRowModel()`), `sortingFn` is `sortFn`, and `ColumnDef` takes the features' type first. Use `tanstackTable`
307
+ instead; it needs no DOM, so the columns no longer wait for a mount.
308
+
309
+ ## **Export (Node; peer: playwright)**
310
+
311
+ `@sportsdataverse/sdvtables/export` renders tables to PNG in headless Chromium. Its functions port sdvplotR's
312
+ `gt_save_crop`, `gt_social_crop`, `gt_save_batch`, `gt_grid` and `gt_stack_tables`, with the Python package's
313
+ defaults. Install the optional peer and its browser once:
314
+
315
+ ```sh
316
+ pnpm add -D playwright
317
+ pnpm exec playwright install chromium
318
+ ```
319
+
320
+ Without `playwright` the functions throw `OptionalDependencyError` (from `@sportsdataverse/sdvplot`), and a missing
321
+ browser says to run `playwright install chromium`. Arguments are checked before any browser starts.
322
+
323
+ ```ts
324
+ import { batchToPNG, gridTables, htmlToPNG, socialCrop, stackTables, tableToPNG } from "@sportsdataverse/sdvtables/export";
325
+
326
+ // spec and rows from the Interactive example; await prepare(spec) first
327
+ await tableToPNG(spec, rows, { file: "afc.png" }); // gt_save_crop
328
+ await socialCrop(spec, rows, { aspect: "4:5", file: "afc-post.png" }); // gt_social_crop
329
+ const west = { spec, rows: rows.filter((r) => r.division === "West") };
330
+ const east = { spec, rows: rows.filter((r) => r.division === "East") };
331
+ await htmlToPNG(gridTables([west, east], { ncol: 2, title: "AFC by division" }), { file: "grid.png" }); // gt_grid
332
+ await htmlToPNG(stackTables([west, east], { title: "AFC by division" }), { file: "stack.png" }); // gt_stack_tables
333
+ await batchToPNG(rows, "division", (groupRows) => ({ spec, rows: groupRows }), "afc-{group}.png", { dir: "out" });
334
+ // gt_save_batch: out/afc-west.png and out/afc-east.png
335
+ ```
336
+
337
+ - `tableToPNG`, `socialCrop` and `htmlToPNG` return the PNG bytes and also write `file` when given (it must end in
338
+ `.png`); `batchToPNG` returns the paths it wrote.
339
+ - An image past Chromium's canvas limit (65,535 px a side, 268,435,456 px in all: near 1,000 plain rows at the
340
+ default zoom) throws `SdvplotError` naming its size, and nothing is written. Put fewer rows in each image
341
+ (`rows.slice()` a page at a time, `batchToPNG` by a group column, `gridTables` side by side) or lower
342
+ `deviceScaleFactor`.
343
+ - Options (`PNGOptions`; `SocialCropOptions` and `BatchOptions` build on it) are in image pixels, as in Python:
344
+ `deviceScaleFactor` (the zoom, default 2), `whitespace` (the margin left around the trimmed content, default 50, or
345
+ 60 for `socialCrop`), `background` (default white), `width` (the final image width; absent, the rendered width) and
346
+ `fontLinks` (extra stylesheets to wait for).
347
+ - `socialCrop` puts the table on an `aspect` canvas (`"1:1"` by default, `"16:9"`, `"4:5"`, `"9:16"`, `"1.91:1"` or
348
+ a number), placed by `gravity` (one of the nine ImageMagick names, `"center"` by default); the table is never cropped.
349
+ - `batchToPNG` writes one image per value of the group column, with `{group}` replaced by `slug(value)`, all widened
350
+ to the widest (`matchWidth: false` keeps each width). A group whose table fails to build is skipped and named in one
351
+ warning, as in Python.
352
+ - `gridTables` and `stackTables` return HTML: the tables with a page title, subtitle, caption and source note around
353
+ them, styled with the Python defaults. A spec used twice gets distinct ids, so each copy keeps its decorations.
354
+ - For an SVG figure, use `toPNG` and `socialCard` from `@sportsdataverse/sdvplot/export` instead (no browser).
355
+
356
+ ## **gtUtils / sdvplotR names**
357
+
358
+ All 74 sdvplotR `gt_*` / `pal_*` / `reactable_sdv_*` exports map as below (`GT_ALIASES` and `aliasFor(name)` expose
359
+ the same table in code). Builder methods are on `defineTable()`, `c.*` inside `.columns(c => [...])`.
360
+
361
+ | sdvplotR / gtUtils | sdvtables | status |
362
+ |---|---|---|
363
+ | `gt_sdv_logos` | `c.logo(key, {league, includeName, season, variant, height})` | ported |
364
+ | `gt_sdv_wordmarks` | `c.wordmark(key, {league, season, variant, height})` | ported |
365
+ | `gt_sdv_headshots` | `c.headshot(key, {league, idSystem, height})` | ported |
366
+ | `gt_sdv_cols_label` | image labels are not ported; use `.titleHeader()` or `subheader` | not ported (labels are text) |
367
+ | `gt_merge_stack_team_color` | `c.mergeStackTeamColor(top, stack, team, {league, fontSizeTop, fontSizeBottom, color, background})` | ported |
368
+ | `gt_theme_sdv` / `gt_theme_sdv_team` | `.theme("sdv", {options:{style}})` / `.theme("sdvTeam", {options:{league, team}})` | ported |
369
+ | `gt_theme_<name>` (18) | `.theme("<name>", {density, options:{accent\|stripe\|paper\|color\|style}})` | ported |
370
+ | `gt_theme_preview` | `themePreview(spec, rows, themes?, {n, density})` from `/html` | ported (dict of HTML, not a grid) |
371
+ | `pal_midnight` | `PAL_MIDNIGHT` (`["#5B8DEF","#3FBF87","#E8E9ED","#9498A3","#24272E"]`) | ported |
372
+ | `gt_538_caption` | `.caption538({top, bottom, ruleColor, ruleWidth, size, align})` | ported |
373
+ | `gt_bold_rows` | `.boldRows(rows, {textColor, highlightColor})` | ported |
374
+ | `gt_border_bars_top/bottom` | `.borderBars("top"\|"bottom", colors, {...})` | ported (bars outside the `<table>`) |
375
+ | `gt_border_grid` | `.borderGrid({color, weight, includeLabels})` | ported |
376
+ | `gt_color_pills` | `c.colorPills(key, {...})` | ported (sRGB ramp, as great_tables) |
377
+ | `gt_color_ranks` | `c.colorRanks(key, {palette, domain, reverse})` | ported |
378
+ | `gt_color_results` | `c.colorResults(key, {...})`: a column kind that fills its row by W/L/T | ported |
379
+ | `gt_column_subheaders` | the `subheader` option of every `c.*` kind | ported |
380
+ | `gt_cutline` | `.cutline(after, {label, color, weight, style, labelColor, labelSize, labelPosition, gap})` | ported |
381
+ | `gt_delta` | `c.delta(from, to, {...})` | ported |
382
+ | `gt_fmt_rank` / `gt_fmt_tally` | `c.rank` / `c.tally` (also `c.fmtRank` / `c.fmtTally`) | ported |
383
+ | `gt_group_stripes` | `.groupBy(key).groupStripes({color, start})` | ported |
384
+ | `gt_highlight_cells` | `c.highlight(key, predicate, {fill, textColor, bold})` | ported (data predicate, not a formula) |
385
+ | `gt_highlight_na` | `c.highlightNa(key, {...})` | ported |
386
+ | `gt_indicator_boxes` | `c.indicatorBox(key, {truthy, fill, neutral, size, showOnly})` | ported (value list, not a rule) |
387
+ | `gt_legend_continuous` / `gt_legend_discrete` (deprecated gtUtils names `gt_color_legend` / `gt_centered_legend`) | `.legendContinuous({...})` / `.legendDiscrete(key\|"recorded", {...})` | ported |
388
+ | `gt_marginalia` | `.marginalia(columns, {...})` | ported |
389
+ | `gt_outliers` | `.outliers(columns, {method, threshold, bounds, side, fill, color, bold, symbol, note})` | ported |
390
+ | `gt_percentile_bar` | `c.percentileBar(key, {...})` | ported |
391
+ | `gt_row_accent` | `.rowAccent(key, {palette, rows, width, side, hide, naColor})` | ported |
392
+ | `gt_scale_note` | `.scaleNote(columns, {divisor, note, where, labelSuffix, decimals})` | ported |
393
+ | `gt_set_font` | `.font(family, {google, weight, style})` | ported |
394
+ | `gt_significance` | `.significance(pairs, {levels, symbols, superscript, note})` | ported |
395
+ | `gt_snake` / `gt_snake_align` | `.snake({nCols, rowsPerCol, gap, fill, cleanGaps})` / `snakeAlign(rows, opts)` | ported |
396
+ | `gt_social_tag` | `.socialTag(accounts, {...})` | ported (inline Font Awesome 6 SVG) |
397
+ | `gt_spotlight` | `.spotlight(rows, {...})` | ported |
398
+ | `gt_tiers` | `.tiers(levels, tierKey, imageColumns, {colors, imgHeight, style})` | ported |
399
+ | `gt_title_header` | `.titleHeader(title, {subtitle, kicker, date, *Style})` | ported |
400
+ | `gt_watermark` | `.watermark({text, image, opacity, size, position, color, angle, font})` | ported |
401
+ | `gt_wrap_labels` | `.wrapLabels({columns, width, balance})` | ported |
402
+ | `gt_save_crop` / `gt_save_batch` | `tableToPNG` / `batchToPNG` (`/export`) | ported |
403
+ | `gt_social_crop` | `socialCrop` (`/export`) | ported |
404
+ | `gt_grid` / `gt_stack_tables` | `gridTables` / `stackTables` (`/export`) | ported |
405
+ | `reactable_sdv_logos/wordmarks/headshots` | `c.logo` / `c.wordmark` / `c.headshot` | ported |
406
+ | `reactable_sdv_cols_label` | as `gt_sdv_cols_label` | not ported |
407
+ | `reactable_sdv_team_color_bar` / `_bg` | `c.teamColorBar(key, {league, which, naColor, barWidth})` / `c.teamColorBg(key, {league, which, alpha, naColor})` | ported |
408
+
409
+ ## **Known differences from Python and R**
410
+
411
+ - Indicator boxes take a value list and carry no text; legends draw discrete steps, not a gradient bar.
412
+ - Tier images have an empty `alt`. `tally`'s column-mode share is not ported.
413
+ - `snake` keeps the first block's row styles for every block.
414
+ - A decoration's row fill always beats a kind's own cell background (Python's outcome depends on call order).
415
+ - `socialTag` accepts a fixed alias set (`x`/`twitter`, `ig`, `bsky`, `gh`, `yt`, `fb`, `web`/`website`/`link`,
416
+ `email`/`mail`), not every Font Awesome name; `tiktok`, `threads` and `substack` are unavailable, as in faicons.
417
+ - `kicker` always renders uppercase; `cutline` y positions round half-up.
418
+ - `wrapLabels` keeps internal whitespace of a label as given.
419
+
420
+ ## **Visual pass (contributors)**
421
+
422
+ The unit snapshots pin each theme's CSS text, not what it draws. The visual pass renders every registered theme's
423
+ `themePreview` of a 2024 AFC standings table (title, subtitle, division groups, source note) through the `tableToPNG`
424
+ path, one PNG per theme:
425
+
426
+ ```sh
427
+ pnpm --filter @sportsdataverse/sdvtables exec playwright install chromium # once
428
+ pnpm --filter @sportsdataverse/sdvtables render:themes # writes packages/sdvtables/theme-pass/<theme>.png
429
+ SDV_RENDER_TESTS=1 pnpm --filter @sportsdataverse/sdvtables test # the unit tests plus the chromium export tests
430
+ ```
431
+
432
+ Open the PNGs after changing a theme or the base sheet: this pass caught tufte and booktabs running the title and the
433
+ subtitle together on one line. `theme-pass/` is gitignored; `SDV_THEME_PASS_DIR=<dir>` writes elsewhere. An always-on
434
+ test fails if a registered theme is missing from the pass. In CI the `render-tests` job runs the same pass, lists the
435
+ PNGs and any failed theme in the job summary, and uploads them as the `sdvtables-theme-pass` artifact; a failing theme
436
+ never fails the build.
437
+
438
+ ## **Documentation**
439
+
440
+ The [**sdvplot-js** documentation website](https://plot.sportsdataverse.org) has the
441
+ [sdvtables gallery](https://plot.sportsdataverse.org/gallery/sdvtables/), the [notebooks](https://plot.sportsdataverse.org/notebooks/) and the
442
+ [sdvtables API reference](https://plot.sportsdataverse.org/api/sdvtables/), one page per subpath, plus:
443
+
444
+ **Guides:**
445
+ [Tables: the spec](https://plot.sportsdataverse.org/guides/tables) ·
446
+ [Table themes](https://plot.sportsdataverse.org/guides/table-themes) ·
447
+ [Cell kinds](https://plot.sportsdataverse.org/guides/cell-kinds) ·
448
+ [Decorations](https://plot.sportsdataverse.org/guides/decorations) ·
449
+ [HTML, SSR and the host page](https://plot.sportsdataverse.org/guides/tables-html) ·
450
+ [Export to PNG](https://plot.sportsdataverse.org/guides/export) ·
451
+ [Node and SSR](https://plot.sportsdataverse.org/guides/node-ssr)
452
+
453
+ **Examples:**
454
+ [Linked figures and tables](https://plot.sportsdataverse.org/examples/linked) ·
455
+ [From sdvplot (Python) and sdvplotR](https://plot.sportsdataverse.org/guides/migrating)
456
+
457
+ The [repository README](https://github.com/sportsdataverse/sdvplot-js#readme) covers all three packages and development.
458
+
459
+ ## **Logos, trademarks and data**
460
+
461
+ Team names, logos, wordmarks and player headshots are trademarks or copyrighted works of their respective leagues,
462
+ teams, schools and other rights holders. `@sportsdataverse/sdvtables` is not affiliated with, sponsored by or endorsed
463
+ by any of them, and using it to draw a mark grants no right to use it. The package ships no logo or headshot files:
464
+ it draws them through `@sportsdataverse/sdvplot`, which carries only an index of team names, ids and colors, and
465
+ the marks are fetched at runtime from the [SportsDataverse logo archive](https://github.com/sportsdataverse/sdv-assets),
466
+ headshots from ESPN (or, for NFL gsis ids, the headshot URLs in nflverse's player table). Use of any mark in your own
467
+ work is governed by that owner's terms, and following them is your responsibility.
468
+
469
+ Logos, headshots and team colors come from `@sportsdataverse/sdvplot`; the package makes no network requests of
470
+ its own beyond the optional Google Fonts `<link>` (`fonts: false` omits it). Social icons are Font Awesome Free 6
471
+ paths ([CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), https://fontawesome.com). The table helpers port
472
+ sdvplot's `great_tables` helpers (Python, MIT) and sdvplotR's `gt_*` functions (MIT).
473
+
474
+ The [MIT license](https://github.com/sportsdataverse/sdvplot-js/blob/main/LICENSE) covers the code; team data belongs to its respective owners and sources.
475
+ [NOTICE.md](https://github.com/sportsdataverse/sdvplot-js/blob/main/packages/sdvtables/NOTICE.md) records the sdvplot/sdvplotR and Font Awesome material and its licences.
476
+
477
+ ## **The SportsDataverse**
478
+
479
+ `@sportsdataverse/sdvtables` is one of the three [sdvplot-js](https://github.com/sportsdataverse/sdvplot-js#readme) packages, with [`@sportsdataverse/sdvplot`](https://github.com/sportsdataverse/sdvplot-js/tree/main/packages/sdvplot#readme) and [`@sportsdataverse/sporty`](https://github.com/sportsdataverse/sdvplot-js/tree/main/packages/sporty#readme).
480
+
481
+ | Package | Sport / Scope |
482
+ | --- | --- |
483
+ | [**sdvplot**](https://sdvplot.sportsdataverse.org/) | The Python package sdvplot-js ports |
484
+ | [**sdvplotR**](https://sdvplotR.sportsdataverse.org/) | The R package sdvplot mirrors |
485
+ | [**sportyR**](https://github.com/sportsdataverse/sportyR) · [**sportypy**](https://sportypy.sportsdataverse.org/) | Playing-surface plots for R and Python, which `@sportsdataverse/sporty` ports |
486
+ | [**sportsdataverse.js**](https://js.sportsdataverse.org/) | SportsDataverse data for Node.js and TypeScript |
487
+ | [**sportsdataverse-py**](https://py.sportsdataverse.org/) | SportsDataverse data for Python: NFL, CFB, NBA, WNBA, MBB, WBB, MLB, NHL, PWHL, soccer and more |
488
+ | [**sportsdataverse-R**](https://r.sportsdataverse.org/) | The R packages: hoopR, wehoop, cfbfastR, fastRhockey, baseballr and more |
489
+
490
+ See the full ecosystem at [sportsdataverse.org](https://sportsdataverse.org/).
491
+
492
+ ## **Follow the [SportsDataverse](https://x.com/SportsDataverse) on X and star this repo**
493
+
494
+ [![Twitter Follow](https://img.shields.io/twitter/follow/SportsDataverse?color=blue&label=%40SportsDataverse&logo=x&style=for-the-badge)](https://x.com/SportsDataverse)
495
+ [![GitHub stars](https://img.shields.io/github/stars/sportsdataverse/sdvplot-js.svg?color=eee&logo=github&style=for-the-badge&label=Star%20sdvplot-js&maxAge=2592000)](https://github.com/sportsdataverse/sdvplot-js/stargazers)
496
+
497
+ ## **Our Authors**
498
+
499
+ - [Saiem Gilani](https://x.com/saiemgilani)
500
+ <a href="https://x.com/saiemgilani" target="blank"><img src="https://img.shields.io/twitter/follow/saiemgilani?color=blue&label=%40saiemgilani&logo=x&style=for-the-badge" alt="@saiemgilani" /></a>
501
+ <a href="https://github.com/saiemgilani" target="blank"><img src="https://img.shields.io/github/followers/saiemgilani?color=eee&logo=Github&style=for-the-badge" alt="@saiemgilani" /></a>
502
+
503
+ ## **Citations**
504
+
505
+ To cite [**`@sportsdataverse/sdvtables`**](https://plot.sportsdataverse.org) in publications, cite sdvplot-js:
506
+
507
+ BibTeX Citation
508
+
509
+ ```bibtex
510
+ @misc{gilani_2026_sdvplot_js,
511
+ author = {Gilani, Saiem},
512
+ title = {sdvplot-js: Team identity, colors, logos, surfaces and tables for JavaScript and TypeScript plots},
513
+ url = {https://plot.sportsdataverse.org},
514
+ year = {2026}
515
+ }
516
+ ```
517
+
518
+ ## **License**
519
+
520
+ MIT. See [NOTICE.md](https://github.com/sportsdataverse/sdvplot-js/blob/main/packages/sdvtables/NOTICE.md) for the sdvplot/sdvplotR and Font Awesome material.