@sportsdataverse/sdvtables 0.0.0-stage → 0.1.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,562 @@
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
+ ## **TanStack Table interop (recipe)**
94
+
95
+ sdvtables has its own headless engine and takes no TanStack dependency. If your app already renders tables with
96
+ `@tanstack/react-table` 8, map a `TableSpec` to `ColumnDef`s: the header comes from the spec, and each
97
+ cell reuses the markup `renderHTML` draws (logos, pills, bars), read back from `toElement()` by `data-row` / `data-col`.
98
+ Sorting uses the spec's `sortable` and `compare`, the fields [the engine](#interactive) reads: a column without
99
+ `compare` falls back to TanStack's default sort, and `compare` stays ascending because TanStack inverts it for
100
+ descending itself.
101
+
102
+ `toElement` needs a `document`, and a `"use client"` component is still server-rendered in Next, so build the columns
103
+ only after mount: in a `useEffect` (below), or load the table component with `next/dynamic(..., { ssr: false })`.
104
+
105
+ ```tsx
106
+ import type { TableSpec } from "@sportsdataverse/sdvtables";
107
+ import { columnLabel, toElement } from "@sportsdataverse/sdvtables/html";
108
+ import type { ColumnDef } from "@tanstack/react-table";
109
+
110
+ /** sdvtables columns -> TanStack column defs. Cells reuse renderHTML's own markup (logos, pills, bars); wrap the
111
+ * table in `<div className={themeKey(spec.theme)}>` and emit `<style>{styleSheet(spec)}</style>` once. */
112
+ export function toTanStackColumns<Row>(
113
+ spec: TableSpec<Row>,
114
+ rows: readonly Row[],
115
+ ): ColumnDef<Row, unknown>[] {
116
+ const cells = new Map<string, Element>(); // "row|col" -> sdvtables' <td>
117
+ // needs a DOM (throws TableSpecError without a document): call this after mount, never during render
118
+ for (const td of Array.from(toElement(spec, rows).querySelectorAll("tr[data-row] > td[data-col]")))
119
+ cells.set(`${td.parentElement?.getAttribute("data-row")}|${td.getAttribute("data-col")}`, td);
120
+ return spec.columns.map(
121
+ (c): ColumnDef<Row, unknown> => ({
122
+ id: c.key,
123
+ accessorFn: (row) => row[c.key],
124
+ header: columnLabel(c),
125
+ enableSorting: c.sortable !== false,
126
+ ...(c.compare ? { sortingFn: (a, b, id) => c.compare?.(a.getValue(id), b.getValue(id)) ?? 0 } : {}),
127
+ cell: (ctx) => {
128
+ const cell = cells.get(`${ctx.row.index}|${c.key}`); // row.index is the input order, as data-row is
129
+ // pill / rank fills live on sdvtables' own <td style>, so carry that over too
130
+ return (
131
+ <div
132
+ ref={(n) => n?.setAttribute("style", cell?.getAttribute("style") ?? "")}
133
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: sdvtables escapes every value it renders
134
+ dangerouslySetInnerHTML={{ __html: cell?.innerHTML ?? "" }}
135
+ />
136
+ );
137
+ },
138
+ }),
139
+ );
140
+ }
141
+ ```
142
+
143
+ ```tsx
144
+ "use client";
145
+ import type { TableSpec } from "@sportsdataverse/sdvtables";
146
+ import { prepare, styleSheet, themeKey } from "@sportsdataverse/sdvtables/html";
147
+ import { type ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
148
+ import { type ReactElement, useEffect, useState } from "react";
149
+ import { toTanStackColumns } from "./to-tanstack-columns"; // the first fence
150
+
151
+ export function StandingsTable<Row>({ spec, rows }: { spec: TableSpec<Row>; rows: Row[] }): ReactElement {
152
+ // empty on the server and on the first client render; filled once the effect has run
153
+ const [columns, setColumns] = useState<ColumnDef<Row, unknown>[]>([]);
154
+ useEffect(() => {
155
+ let live = true;
156
+ void prepare(spec).then(() => {
157
+ if (live) setColumns(toTanStackColumns(spec, rows));
158
+ });
159
+ return () => {
160
+ live = false;
161
+ };
162
+ }, [spec, rows]);
163
+
164
+ const table = useReactTable({
165
+ data: rows, // keep this reference stable: a new array every render makes TanStack loop
166
+ columns,
167
+ getCoreRowModel: getCoreRowModel(),
168
+ getSortedRowModel: getSortedRowModel(),
169
+ });
170
+ return (
171
+ <div className={themeKey(spec.theme)}>
172
+ <style>{styleSheet(spec)}</style>
173
+ <table>
174
+ <thead>
175
+ {table.getHeaderGroups().map((g) => (
176
+ <tr key={g.id}>
177
+ {g.headers.map((h) => (
178
+ <th key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</th>
179
+ ))}
180
+ </tr>
181
+ ))}
182
+ </thead>
183
+ <tbody>
184
+ {table.getRowModel().rows.map((r) => (
185
+ <tr key={r.id}>
186
+ {r.getVisibleCells().map((c) => (
187
+ <td key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</td>
188
+ ))}
189
+ </tr>
190
+ ))}
191
+ </tbody>
192
+ </table>
193
+ </div>
194
+ );
195
+ }
196
+ ```
197
+
198
+ Limits: `snake` and tier layouts change which `<tr>` holds which row, so the recipe covers plain specs; table-level
199
+ decorations (title, caption, stripes) are not cells and are not carried over. Written for `@tanstack/react-table` 8
200
+ (sdv-web pins `^8.21.3`); v9 is a breaking major and is not covered.
201
+
202
+ ## **Themes**
203
+
204
+ 20 themes: the 18 gtUtils themes plus `sdv` (light/dark) and `sdvTeam`. Set one with
205
+ `.theme(name, { density, options })`.
206
+
207
+ | Theme | Options | Default density |
208
+ |---|---|---|
209
+ | `sdv` | `style` (`light`/`dark`) | comfortable |
210
+ | `sdvTeam` | `league` (required), `team` | comfortable |
211
+ | `almanac` | `accent`, `stripe` (`"none"` for no banding) | compact |
212
+ | `broadsheet` | `accent`, `paper` | comfortable |
213
+ | `drench` | `color` | comfortable |
214
+ | `sofa`, `tier` | `style` | comfortable |
215
+ | `scoreboard`, `terminal` | `accent` | compact |
216
+ | `booktabs`, `brutalist`, `midnight`, `swiss`, `tufte` | `accent` | comfortable |
217
+ | `athletic`, `gtutils`, `kenpom`, `ncaa`, `pl`, `savant` | none | comfortable |
218
+
219
+ | Density | body | padding | title | subtitle | label | group | source |
220
+ |---|---|---|---|---|---|---|---|
221
+ | `comfortable` | 14 | 6 | 26 | 15 | 10 | 11 | 11 |
222
+ | `compact` | 12 | 3 | 22 | 13 | 9 | 10 | 10 |
223
+ | `social` | 17 | 9 | 34 | 19 | 12 | 13 | 13 |
224
+
225
+ `themePreview(spec, rows, themes?, { n, density })` from `@sportsdataverse/sdvtables/html` returns
226
+ `Record<themeName, html>` (first `n` rows, compact).
227
+
228
+ ## **Surface**
229
+
230
+ - 21 column kinds on the `c` factory (`text`, `num`, `int`, `pct`, `rank`, `delta`, `tally`, `logo`, `wordmark`,
231
+ `headshot`, `colorPills`, `colorRanks`, `colorResults`, `percentileBar`, `indicatorBox`, `highlight`,
232
+ `highlightNa`, `mergeStackTeamColor`, `teamColorBar`, `teamColorBg`, `image`; `fmtRank`/`fmtTally` alias
233
+ `rank`/`tally`).
234
+ - 25 table decorations as builder methods (titles, legends, cutlines, row accents, snake layout, tiers, watermark, ...).
235
+ - Conditions are data predicates (`{ key, op, value }`), not callbacks, so specs stay serializable.
236
+ - Number cells display negatives with U+2212 (a true minus); `formatValue`-style labels (legends, notes) stay ASCII.
237
+ - Unknown team ids warn once per call per set and render as plain text.
238
+
239
+ ## **Interactive**
240
+
241
+ `createTable(spec, rows, options)` is the package's own headless engine (no table library): sort, per-column and
242
+ global text filters, paging, column visibility and row selection, with immutable snapshots and `subscribe`. Every
243
+ column sorts unless `sortable: false`, missing values sort last in both directions, and a column's `compare(a, b)`
244
+ replaces the kind's comparator. `filterable: true` gives a column its own filter box; the search box searches every
245
+ shown column. The page size is `options.pageSize`, else `spec.interactive.pageSize`, else one page.
246
+
247
+ ```ts
248
+ import { createTable, defineTable } from "@sportsdataverse/sdvtables";
249
+ import { hydrate, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
250
+
251
+ // 2024 AFC regular season (nflverse games)
252
+ const rows = [
253
+ { team: "KC", division: "West", wins: 15, losses: 2, pf: 385, pa: 326 },
254
+ { team: "LAC", division: "West", wins: 11, losses: 6, pf: 402, pa: 301 },
255
+ { team: "DEN", division: "West", wins: 10, losses: 7, pf: 425, pa: 311 },
256
+ { team: "LV", division: "West", wins: 4, losses: 13, pf: 309, pa: 434 },
257
+ { team: "BUF", division: "East", wins: 13, losses: 4, pf: 525, pa: 368 },
258
+ { team: "MIA", division: "East", wins: 8, losses: 9, pf: 345, pa: 364 },
259
+ { team: "NYJ", division: "East", wins: 5, losses: 12, pf: 338, pa: 404 },
260
+ { team: "NE", division: "East", wins: 4, losses: 13, pf: 289, pa: 417 },
261
+ ];
262
+ type Row = (typeof rows)[number];
263
+ const spec = defineTable<Row>()
264
+ .columns((c) => [
265
+ c.logo("team", { league: "nfl", includeName: true }),
266
+ c.text("division", { filterable: true }),
267
+ c.int("wins"),
268
+ c.int("losses"),
269
+ c.int("pf", { label: "PF" }),
270
+ c.int("pa", { label: "PA" }),
271
+ ])
272
+ .title("AFC")
273
+ .subtitle("2024 regular season")
274
+ .rowKey("team")
275
+ .build();
276
+
277
+ await prepare(spec);
278
+ const table = createTable(spec, rows, { pageSize: 4, sort: { col: "wins", dir: "desc" } });
279
+ const host = document.querySelector("#standings")!;
280
+ host.innerHTML = renderHTML(table); // the same string renders on a server or a static site
281
+ const stop = hydrate(host.querySelector(".sdvt")!, table); // sort, filter, page and select without React
282
+ table.setFilter("division", "east"); // BUF, MIA, NYJ, NE; back to the first page
283
+ ```
284
+
285
+ `renderHTML(table)` draws sort buttons in the headers (`aria-sort` on the sorted one), a search box and the filter
286
+ boxes above the table, and a pager below it. `hydrate(el, table)` attaches delegated listeners and re-renders only the
287
+ table block and the pager, once, on the next animation frame, however many changes land in it; it returns a teardown.
288
+ A click, pointer move or key that arrives while a change to the rows (a sort, filter, page turn or `setRows`) waits for
289
+ 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
290
+ draw replaced is consumed, so the page does not scroll. A change that keeps the rows (a selection, or a live feed
291
+ re-sending a keyed row as a new object) waits for its frame. Render the markup from the same table state you hydrate:
292
+ attaching does not reconcile the two. Hydrating an element again draws any change its previous binding still owed. A
293
+ teardown drops a render still owed; hydrating that element again redraws it first, so React StrictMode's mount,
294
+ cleanup, mount leaves no stale rows.
295
+
296
+ Inside an interactive table: j/k (↓/↑) move between the rows of the page and never turn it (the pager does), h/l
297
+ (←/→) pick a column, s sorts it, / jumps to the search box, Enter or Space toggles the row's selection;
298
+ `interactive: { hotkeys: false }` on the spec keeps only the arrows, Enter and Space. The table is a `role="grid"`
299
+ whose rows share one tab stop, the current row; the current column's header carries
300
+ `aria-current="true"` and a selected row `aria-selected="true"`. Keys pressed with Ctrl, Meta or Alt, during IME
301
+ composition, or in a text field are left alone. Clicking a row toggles its selection too.
302
+
303
+ In an interactive table, colour scales, legends, outlier limits and row-accent palettes are computed from all of the
304
+ table's rows, not the page on screen, so a value keeps its colour while the user pages, filters or sorts. Row
305
+ selectors by index (`.boldRows([0])`) count in the rows passed to `createTable`. `snake` layouts are static only: an
306
+ interactive render of a `.snake()` spec throws `TableSpecError`.
307
+
308
+ Linking a table to a figure: with `.rowKey("team")`, a row's id is `String(row.team)` (without one, its index in the
309
+ input rows). `setSelection(ids)` / `getSelection()`, `setExternalFilter((row) => …)` (ANDed with the table's own
310
+ filters) and `setHover(id)` drive the table from outside, and `subscribe` reports `select`, `hover` and `change`
311
+ events. Setting the same selection or filter again notifies nobody, so a two-way link does not loop.
312
+ `@sportsdataverse/sdvplot/interact`'s `linkSelection` does this wiring against a selection store.
313
+
314
+ Every interactive table, linked or not, underlines the row under the pointer, or the row a linked figure hovers
315
+ (`setHover`): the row gets the `sdvt-hover` class, which re-renders nothing and survives the next re-render. A table
316
+ holds one hover id, so a linked table lights the first id the figure hovers. `getHover()` returns it, so a view
317
+ attached later (a remount, a second hydrate) shows it. Unmounting while the pointer is on a row leaves that hover in
318
+ place, because a removed row fires no `mouseleave`. reactable's `highlight` defaults to `FALSE`; to drop the underline,
319
+ add `tr.sdvt-hover>td.sdvt-cell{background-image:none}` to the page. The built-in rule is wrapped in `:where()`, so
320
+ this one plain rule wins wherever the page puts it. A selection or brush from a linked figure
321
+ rebuilds the table body, so a scroll position inside a cell resets.
322
+
323
+ Engine non-goals: virtualization, column resize/reorder/pin, a grouping UI and server-side paging (slice the rows
324
+ before `createTable`).
325
+
326
+ ### React
327
+
328
+ ```tsx
329
+ import { fontsLinkFor } from "@sportsdataverse/sdvtables/html";
330
+ import { SdvTable, useTable } from "@sportsdataverse/sdvtables/react"; // peer: react >= 18
331
+
332
+ // spec and rows from above; await prepare(spec) before the first render, server included
333
+ <SdvTable spec={spec} rows={rows} interactive pageSize={4} />; // SSR markup === renderHTML(table, { fonts: false })
334
+
335
+ function Standings() {
336
+ const { table, snapshot } = useTable(spec, rows, { pageSize: 4 });
337
+ return (
338
+ <>
339
+ <p>{snapshot.filteredCount} teams</p>
340
+ <SdvTable table={table} interactive />
341
+ </>
342
+ );
343
+ }
344
+ ```
345
+
346
+ `<SdvTable/>` writes no fonts `<link>`: put the tag `fontsLinkFor(spec)` returns in the page `<head>`. `pageSize` and
347
+ `sort` are initial state, like React's `default*` props. A new `rows` array, even an equal copy, keeps the user's
348
+ sort, filters, page and selection; only a structural `spec` change builds a new engine. Without `interactive` the
349
+ component renders the static table. The `react` entry is marked `"use client"`.
350
+
351
+ ## **Export (Node; peer: playwright)**
352
+
353
+ `@sportsdataverse/sdvtables/export` renders tables to PNG in headless Chromium. Its functions port sdvplotR's
354
+ `gt_save_crop`, `gt_social_crop`, `gt_save_batch`, `gt_grid` and `gt_stack_tables`, with the Python package's
355
+ defaults. Install the optional peer and its browser once:
356
+
357
+ ```sh
358
+ pnpm add -D playwright
359
+ pnpm exec playwright install chromium
360
+ ```
361
+
362
+ Without `playwright` the functions throw `OptionalDependencyError` (from `@sportsdataverse/sdvplot`), and a missing
363
+ browser says to run `playwright install chromium`. Arguments are checked before any browser starts.
364
+
365
+ ```ts
366
+ import { batchToPNG, gridTables, htmlToPNG, socialCrop, stackTables, tableToPNG } from "@sportsdataverse/sdvtables/export";
367
+
368
+ // spec and rows from the Interactive example; await prepare(spec) first
369
+ await tableToPNG(spec, rows, { file: "afc.png" }); // gt_save_crop
370
+ await socialCrop(spec, rows, { aspect: "4:5", file: "afc-post.png" }); // gt_social_crop
371
+ const west = { spec, rows: rows.filter((r) => r.division === "West") };
372
+ const east = { spec, rows: rows.filter((r) => r.division === "East") };
373
+ await htmlToPNG(gridTables([west, east], { ncol: 2, title: "AFC by division" }), { file: "grid.png" }); // gt_grid
374
+ await htmlToPNG(stackTables([west, east], { title: "AFC by division" }), { file: "stack.png" }); // gt_stack_tables
375
+ await batchToPNG(rows, "division", (groupRows) => ({ spec, rows: groupRows }), "afc-{group}.png", { dir: "out" });
376
+ // gt_save_batch: out/afc-west.png and out/afc-east.png
377
+ ```
378
+
379
+ - `tableToPNG`, `socialCrop` and `htmlToPNG` return the PNG bytes and also write `file` when given (it must end in
380
+ `.png`); `batchToPNG` returns the paths it wrote.
381
+ - 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
382
+ default zoom) throws `SdvplotError` naming its size, and nothing is written. Put fewer rows in each image
383
+ (`rows.slice()` a page at a time, `batchToPNG` by a group column, `gridTables` side by side) or lower
384
+ `deviceScaleFactor`.
385
+ - Options (`PNGOptions`; `SocialCropOptions` and `BatchOptions` build on it) are in image pixels, as in Python:
386
+ `deviceScaleFactor` (the zoom, default 2), `whitespace` (the margin left around the trimmed content, default 50, or
387
+ 60 for `socialCrop`), `background` (default white), `width` (the final image width; absent, the rendered width) and
388
+ `fontLinks` (extra stylesheets to wait for).
389
+ - `socialCrop` puts the table on an `aspect` canvas (`"1:1"` by default, `"16:9"`, `"4:5"`, `"9:16"`, `"1.91:1"` or
390
+ a number), placed by `gravity` (one of the nine ImageMagick names, `"center"` by default); the table is never cropped.
391
+ - `batchToPNG` writes one image per value of the group column, with `{group}` replaced by `slug(value)`, all widened
392
+ to the widest (`matchWidth: false` keeps each width). A group whose table fails to build is skipped and named in one
393
+ warning, as in Python.
394
+ - `gridTables` and `stackTables` return HTML: the tables with a page title, subtitle, caption and source note around
395
+ them, styled with the Python defaults. A spec used twice gets distinct ids, so each copy keeps its decorations.
396
+ - For an SVG figure, use `toPNG` and `socialCard` from `@sportsdataverse/sdvplot/export` instead (no browser).
397
+
398
+ ## **gtUtils / sdvplotR names**
399
+
400
+ All 74 sdvplotR `gt_*` / `pal_*` / `reactable_sdv_*` exports map as below (`GT_ALIASES` and `aliasFor(name)` expose
401
+ the same table in code). Builder methods are on `defineTable()`, `c.*` inside `.columns(c => [...])`.
402
+
403
+ | sdvplotR / gtUtils | sdvtables | status |
404
+ |---|---|---|
405
+ | `gt_sdv_logos` | `c.logo(key, {league, includeName, season, variant, height})` | ported |
406
+ | `gt_sdv_wordmarks` | `c.wordmark(key, {league, season, variant, height})` | ported |
407
+ | `gt_sdv_headshots` | `c.headshot(key, {league, idSystem, height})` | ported |
408
+ | `gt_sdv_cols_label` | image labels are not ported; use `.titleHeader()` or `subheader` | not ported (labels are text) |
409
+ | `gt_merge_stack_team_color` | `c.mergeStackTeamColor(top, stack, team, {league, fontSizeTop, fontSizeBottom, color, background})` | ported |
410
+ | `gt_theme_sdv` / `gt_theme_sdv_team` | `.theme("sdv", {options:{style}})` / `.theme("sdvTeam", {options:{league, team}})` | ported |
411
+ | `gt_theme_<name>` (18) | `.theme("<name>", {density, options:{accent\|stripe\|paper\|color\|style}})` | ported |
412
+ | `gt_theme_preview` | `themePreview(spec, rows, themes?, {n, density})` from `/html` | ported (dict of HTML, not a grid) |
413
+ | `pal_midnight` | `PAL_MIDNIGHT` (`["#5B8DEF","#3FBF87","#E8E9ED","#9498A3","#24272E"]`) | ported |
414
+ | `gt_538_caption` | `.caption538({top, bottom, ruleColor, ruleWidth, size, align})` | ported |
415
+ | `gt_bold_rows` | `.boldRows(rows, {textColor, highlightColor})` | ported |
416
+ | `gt_border_bars_top/bottom` | `.borderBars("top"\|"bottom", colors, {...})` | ported (bars outside the `<table>`) |
417
+ | `gt_border_grid` | `.borderGrid({color, weight, includeLabels})` | ported |
418
+ | `gt_color_pills` | `c.colorPills(key, {...})` | ported (sRGB ramp, as great_tables) |
419
+ | `gt_color_ranks` | `c.colorRanks(key, {palette, domain, reverse})` | ported |
420
+ | `gt_color_results` | `c.colorResults(key, {...})`: a column kind that fills its row by W/L/T | ported |
421
+ | `gt_column_subheaders` | the `subheader` option of every `c.*` kind | ported |
422
+ | `gt_cutline` | `.cutline(after, {label, color, weight, style, labelColor, labelSize, labelPosition, gap})` | ported |
423
+ | `gt_delta` | `c.delta(from, to, {...})` | ported |
424
+ | `gt_fmt_rank` / `gt_fmt_tally` | `c.rank` / `c.tally` (also `c.fmtRank` / `c.fmtTally`) | ported |
425
+ | `gt_group_stripes` | `.groupBy(key).groupStripes({color, start})` | ported |
426
+ | `gt_highlight_cells` | `c.highlight(key, predicate, {fill, textColor, bold})` | ported (data predicate, not a formula) |
427
+ | `gt_highlight_na` | `c.highlightNa(key, {...})` | ported |
428
+ | `gt_indicator_boxes` | `c.indicatorBox(key, {truthy, fill, neutral, size, showOnly})` | ported (value list, not a rule) |
429
+ | `gt_legend_continuous` / `gt_legend_discrete` (deprecated gtUtils names `gt_color_legend` / `gt_centered_legend`) | `.legendContinuous({...})` / `.legendDiscrete(key\|"recorded", {...})` | ported |
430
+ | `gt_marginalia` | `.marginalia(columns, {...})` | ported |
431
+ | `gt_outliers` | `.outliers(columns, {method, threshold, bounds, side, fill, color, bold, symbol, note})` | ported |
432
+ | `gt_percentile_bar` | `c.percentileBar(key, {...})` | ported |
433
+ | `gt_row_accent` | `.rowAccent(key, {palette, rows, width, side, hide, naColor})` | ported |
434
+ | `gt_scale_note` | `.scaleNote(columns, {divisor, note, where, labelSuffix, decimals})` | ported |
435
+ | `gt_set_font` | `.font(family, {google, weight, style})` | ported |
436
+ | `gt_significance` | `.significance(pairs, {levels, symbols, superscript, note})` | ported |
437
+ | `gt_snake` / `gt_snake_align` | `.snake({nCols, rowsPerCol, gap, fill, cleanGaps})` / `snakeAlign(rows, opts)` | ported |
438
+ | `gt_social_tag` | `.socialTag(accounts, {...})` | ported (inline Font Awesome 6 SVG) |
439
+ | `gt_spotlight` | `.spotlight(rows, {...})` | ported |
440
+ | `gt_tiers` | `.tiers(levels, tierKey, imageColumns, {colors, imgHeight, style})` | ported |
441
+ | `gt_title_header` | `.titleHeader(title, {subtitle, kicker, date, *Style})` | ported |
442
+ | `gt_watermark` | `.watermark({text, image, opacity, size, position, color, angle, font})` | ported |
443
+ | `gt_wrap_labels` | `.wrapLabels({columns, width, balance})` | ported |
444
+ | `gt_save_crop` / `gt_save_batch` | `tableToPNG` / `batchToPNG` (`/export`) | ported |
445
+ | `gt_social_crop` | `socialCrop` (`/export`) | ported |
446
+ | `gt_grid` / `gt_stack_tables` | `gridTables` / `stackTables` (`/export`) | ported |
447
+ | `reactable_sdv_logos/wordmarks/headshots` | `c.logo` / `c.wordmark` / `c.headshot` | ported |
448
+ | `reactable_sdv_cols_label` | as `gt_sdv_cols_label` | not ported |
449
+ | `reactable_sdv_team_color_bar` / `_bg` | `c.teamColorBar(key, {league, which, naColor, barWidth})` / `c.teamColorBg(key, {league, which, alpha, naColor})` | ported |
450
+
451
+ ## **Known differences from Python and R**
452
+
453
+ - Indicator boxes take a value list and carry no text; legends draw discrete steps, not a gradient bar.
454
+ - Tier images have an empty `alt`. `tally`'s column-mode share is not ported.
455
+ - `snake` keeps the first block's row styles for every block.
456
+ - A decoration's row fill always beats a kind's own cell background (Python's outcome depends on call order).
457
+ - `socialTag` accepts a fixed alias set (`x`/`twitter`, `ig`, `bsky`, `gh`, `yt`, `fb`, `web`/`website`/`link`,
458
+ `email`/`mail`), not every Font Awesome name; `tiktok`, `threads` and `substack` are unavailable, as in faicons.
459
+ - `kicker` always renders uppercase; `cutline` y positions round half-up.
460
+ - `wrapLabels` keeps internal whitespace of a label as given.
461
+
462
+ ## **Visual pass (contributors)**
463
+
464
+ The unit snapshots pin each theme's CSS text, not what it draws. The visual pass renders every registered theme's
465
+ `themePreview` of a 2024 AFC standings table (title, subtitle, division groups, source note) through the `tableToPNG`
466
+ path, one PNG per theme:
467
+
468
+ ```sh
469
+ pnpm --filter @sportsdataverse/sdvtables exec playwright install chromium # once
470
+ pnpm --filter @sportsdataverse/sdvtables render:themes # writes packages/sdvtables/theme-pass/<theme>.png
471
+ SDV_RENDER_TESTS=1 pnpm --filter @sportsdataverse/sdvtables test # the unit tests plus the chromium export tests
472
+ ```
473
+
474
+ Open the PNGs after changing a theme or the base sheet: this pass caught tufte and booktabs running the title and the
475
+ subtitle together on one line. `theme-pass/` is gitignored; `SDV_THEME_PASS_DIR=<dir>` writes elsewhere. An always-on
476
+ test fails if a registered theme is missing from the pass. In CI the `render-tests` job runs the same pass, lists the
477
+ PNGs and any failed theme in the job summary, and uploads them as the `sdvtables-theme-pass` artifact; a failing theme
478
+ never fails the build.
479
+
480
+ ## **Documentation**
481
+
482
+ The [**sdvplot-js** documentation website](https://plot.sportsdataverse.org) has the
483
+ [sdvtables gallery](https://plot.sportsdataverse.org/gallery/sdvtables/), the [notebooks](https://plot.sportsdataverse.org/notebooks/) and the
484
+ [sdvtables API reference](https://plot.sportsdataverse.org/api/sdvtables/), one page per subpath, plus:
485
+
486
+ **Guides:**
487
+ [Tables: the spec](https://plot.sportsdataverse.org/guides/tables) ·
488
+ [Table themes](https://plot.sportsdataverse.org/guides/table-themes) ·
489
+ [Cell kinds](https://plot.sportsdataverse.org/guides/cell-kinds) ·
490
+ [Decorations](https://plot.sportsdataverse.org/guides/decorations) ·
491
+ [HTML, SSR and the host page](https://plot.sportsdataverse.org/guides/tables-html) ·
492
+ [Export to PNG](https://plot.sportsdataverse.org/guides/export) ·
493
+ [Node and SSR](https://plot.sportsdataverse.org/guides/node-ssr)
494
+
495
+ **Examples:**
496
+ [Linked figures and tables](https://plot.sportsdataverse.org/examples/linked) ·
497
+ [From sdvplot (Python) and sdvplotR](https://plot.sportsdataverse.org/guides/migrating)
498
+
499
+ The [repository README](https://github.com/sportsdataverse/sdvplot-js#readme) covers all three packages and development.
500
+
501
+ ## **Logos, trademarks and data**
502
+
503
+ Team names, logos, wordmarks and player headshots are trademarks or copyrighted works of their respective leagues,
504
+ teams, schools and other rights holders. `@sportsdataverse/sdvtables` is not affiliated with, sponsored by or endorsed
505
+ 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:
506
+ it draws them through `@sportsdataverse/sdvplot`, which carries only an index of team names, ids and colors, and
507
+ the marks are fetched at runtime from the [SportsDataverse logo archive](https://github.com/sportsdataverse/sdv-assets),
508
+ headshots from ESPN (or, for NFL gsis ids, the headshot URLs in nflverse's player table). Use of any mark in your own
509
+ work is governed by that owner's terms, and following them is your responsibility.
510
+
511
+ Logos, headshots and team colors come from `@sportsdataverse/sdvplot`; the package makes no network requests of
512
+ its own beyond the optional Google Fonts `<link>` (`fonts: false` omits it). Social icons are Font Awesome Free 6
513
+ paths ([CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), https://fontawesome.com). The table helpers port
514
+ sdvplot's `great_tables` helpers (Python, MIT) and sdvplotR's `gt_*` functions (MIT).
515
+
516
+ The [MIT license](https://github.com/sportsdataverse/sdvplot-js/blob/main/LICENSE) covers the code; team data belongs to its respective owners and sources.
517
+ [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.
518
+
519
+ ## **The SportsDataverse**
520
+
521
+ `@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).
522
+
523
+ | Package | Sport / Scope |
524
+ | --- | --- |
525
+ | [**sdvplot**](https://sdvplot.sportsdataverse.org/) | The Python package sdvplot-js ports |
526
+ | [**sdvplotR**](https://sdvplotR.sportsdataverse.org/) | The R package sdvplot mirrors |
527
+ | [**sportyR**](https://github.com/sportsdataverse/sportyR) · [**sportypy**](https://sportypy.sportsdataverse.org/) | Playing-surface plots for R and Python, which `@sportsdataverse/sporty` ports |
528
+ | [**sportsdataverse.js**](https://js.sportsdataverse.org/) | SportsDataverse data for Node.js and TypeScript |
529
+ | [**sportsdataverse-py**](https://py.sportsdataverse.org/) | SportsDataverse data for Python: NFL, CFB, NBA, WNBA, MBB, WBB, MLB, NHL, PWHL, soccer and more |
530
+ | [**sportsdataverse-R**](https://r.sportsdataverse.org/) | The R packages: hoopR, wehoop, cfbfastR, fastRhockey, baseballr and more |
531
+
532
+ See the full ecosystem at [sportsdataverse.org](https://sportsdataverse.org/).
533
+
534
+ ## **Follow the [SportsDataverse](https://x.com/SportsDataverse) on X and star this repo**
535
+
536
+ [![Twitter Follow](https://img.shields.io/twitter/follow/SportsDataverse?color=blue&label=%40SportsDataverse&logo=x&style=for-the-badge)](https://x.com/SportsDataverse)
537
+ [![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)
538
+
539
+ ## **Our Authors**
540
+
541
+ - [Saiem Gilani](https://x.com/saiemgilani)
542
+ <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>
543
+ <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>
544
+
545
+ ## **Citations**
546
+
547
+ To cite [**`@sportsdataverse/sdvtables`**](https://plot.sportsdataverse.org) in publications, cite sdvplot-js:
548
+
549
+ BibTeX Citation
550
+
551
+ ```bibtex
552
+ @misc{gilani_2026_sdvplot_js,
553
+ author = {Gilani, Saiem},
554
+ title = {sdvplot-js: Team identity, colors, logos, surfaces and tables for JavaScript and TypeScript plots},
555
+ url = {https://plot.sportsdataverse.org},
556
+ year = {2026}
557
+ }
558
+ ```
559
+
560
+ ## **License**
561
+
562
+ MIT. See [NOTICE.md](https://github.com/sportsdataverse/sdvplot-js/blob/main/packages/sdvtables/NOTICE.md) for the sdvplot/sdvplotR and Font Awesome material.