@sportsdataverse/sdvplot 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 +21 -0
- package/NOTICE.md +94 -0
- package/README.md +908 -2
- package/dist/_web-D7Aa5Rd8.d.ts +69 -0
- package/dist/aaf-GJ3SH34T.js +13 -0
- package/dist/aaf-HCYN5T6Y.js +37 -0
- package/dist/aaf-ZAE5KS2Y.js +13 -0
- package/dist/ahl-E66MDVCU.js +66 -0
- package/dist/ahl-NPQQWZEV.js +249 -0
- package/dist/ahl-QRDU73XX.js +309 -0
- package/dist/axis-CraWQRUJ.d.ts +49 -0
- package/dist/bins.d.ts +174 -0
- package/dist/bins.js +4 -0
- package/dist/bins.js.map +1 -0
- package/dist/cfb-F42SVS4X.js +5000 -0
- package/dist/cfb-P3SHEYRZ.js +692 -0
- package/dist/cfb-WXRZANLP.js +10226 -0
- package/dist/chartjs-surface.d.ts +42 -0
- package/dist/chartjs-surface.js +65 -0
- package/dist/chartjs-surface.js.map +1 -0
- package/dist/chartjs.d.ts +132 -0
- package/dist/chartjs.js +339 -0
- package/dist/chartjs.js.map +1 -0
- package/dist/chunk-4C3GX6QO.js +92 -0
- package/dist/chunk-4C3GX6QO.js.map +1 -0
- package/dist/chunk-C2CDY5OG.js +137 -0
- package/dist/chunk-C2CDY5OG.js.map +1 -0
- package/dist/chunk-E7G7VSLV.js +43 -0
- package/dist/chunk-E7G7VSLV.js.map +1 -0
- package/dist/chunk-J6R2LEJ7.js +58 -0
- package/dist/chunk-J6R2LEJ7.js.map +1 -0
- package/dist/chunk-JJFTD4VB.js +175 -0
- package/dist/chunk-JJFTD4VB.js.map +1 -0
- package/dist/chunk-KKH6ABMA.js +223 -0
- package/dist/chunk-KKH6ABMA.js.map +1 -0
- package/dist/chunk-NLPH2VZT.js +127 -0
- package/dist/chunk-NLPH2VZT.js.map +1 -0
- package/dist/chunk-OKEE4AH6.js +30 -0
- package/dist/chunk-OKEE4AH6.js.map +1 -0
- package/dist/chunk-QY65DUEF.js +306 -0
- package/dist/chunk-QY65DUEF.js.map +1 -0
- package/dist/chunk-RMT2GMFH.js +97 -0
- package/dist/chunk-RMT2GMFH.js.map +1 -0
- package/dist/chunk-SEBO35DV.js +76 -0
- package/dist/chunk-SEBO35DV.js.map +1 -0
- package/dist/chunk-TAXO3VXH.js +556 -0
- package/dist/chunk-TAXO3VXH.js.map +1 -0
- package/dist/chunk-TDNBAEQB.js +105 -0
- package/dist/chunk-TDNBAEQB.js.map +1 -0
- package/dist/cricket-KPCYG2HL.js +493 -0
- package/dist/cricket-NDXLE25B.js +116 -0
- package/dist/cricket-YUUF7P6M.js +160 -0
- package/dist/d3.d.ts +234 -0
- package/dist/d3.js +198 -0
- package/dist/d3.js.map +1 -0
- package/dist/echarts.d.ts +121 -0
- package/dist/echarts.js +228 -0
- package/dist/echarts.js.map +1 -0
- package/dist/echl-3CR4RGFC.js +241 -0
- package/dist/echl-NPC32C23.js +107 -0
- package/dist/echl-PI33DGJC.js +64 -0
- package/dist/export.d.ts +130 -0
- package/dist/export.js +258 -0
- package/dist/export.js.map +1 -0
- package/dist/headshots-B2J_LOO8.d.ts +40 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/interact.d.ts +567 -0
- package/dist/interact.js +809 -0
- package/dist/interact.js.map +1 -0
- package/dist/marks-DH-tQIP-.d.ts +75 -0
- package/dist/mbb-3TRLVW3H.js +3619 -0
- package/dist/mbb-DQUHCHZP.js +5111 -0
- package/dist/mbb-PRAIO2P5.js +371 -0
- package/dist/milb-6IQ6NCEW.js +291 -0
- package/dist/milb-EYY5MD4T.js +1752 -0
- package/dist/milb-EYZ4SZLI.js +797 -0
- package/dist/mlb-4PBUJSVQ.js +35 -0
- package/dist/mlb-FHPY3GQJ.js +701 -0
- package/dist/mlb-REVGL3N2.js +665 -0
- package/dist/nba-ATT775GS.js +485 -0
- package/dist/nba-LKM4UHVU.js +482 -0
- package/dist/nba-YX7PXM3R.js +35 -0
- package/dist/nbagl-3WGZCZ4S.js +37 -0
- package/dist/nbagl-LP4XJRAL.js +229 -0
- package/dist/nbagl-VTTOU7LN.js +499 -0
- package/dist/ncaa_baseball-UV75LNVR.js +2670 -0
- package/dist/ncaa_baseball-VD5J5C72.js +419 -0
- package/dist/ncaa_baseball-YRBS32D6.js +4592 -0
- package/dist/ncaa_mhockey-IMYHS7QS.js +1033 -0
- package/dist/ncaa_mhockey-TXTKWHE3.js +115 -0
- package/dist/ncaa_mhockey-U3MFW5VT.js +663 -0
- package/dist/ncaa_softball-6WR6KS64.js +420 -0
- package/dist/ncaa_softball-RZCTIE56.js +2654 -0
- package/dist/ncaa_softball-VOD4PVGY.js +4599 -0
- package/dist/ncaa_whockey-6XXGUTBK.js +607 -0
- package/dist/ncaa_whockey-AKKYKY67.js +53 -0
- package/dist/ncaa_whockey-KBEKXFBB.js +293 -0
- package/dist/nfl-BDB2HYAR.js +625 -0
- package/dist/nfl-EHYHDLJD.js +37 -0
- package/dist/nfl-VF4NJCHY.js +514 -0
- package/dist/nfl_gsis-UY64JO37.js +4 -0
- package/dist/nhl-6WPHME7H.js +511 -0
- package/dist/nhl-EMIJFZHY.js +884 -0
- package/dist/nhl-ISFUKRFU.js +37 -0
- package/dist/ohl-2FEM554G.js +113 -0
- package/dist/ohl-C5Q4W6JB.js +32 -0
- package/dist/ohl-TQ4J46VC.js +66 -0
- package/dist/phf-H7POU2KW.js +32 -0
- package/dist/phf-K5IFMZUM.js +13 -0
- package/dist/phf-TWJI3BOW.js +20 -0
- package/dist/plot.d.ts +449 -0
- package/dist/plot.js +711 -0
- package/dist/plot.js.map +1 -0
- package/dist/plotly.d.ts +129 -0
- package/dist/plotly.js +360 -0
- package/dist/plotly.js.map +1 -0
- package/dist/pwhl-OX3BO4JI.js +53 -0
- package/dist/pwhl-W44OC3C6.js +17 -0
- package/dist/pwhl-ZJGG3H7W.js +23 -0
- package/dist/qmjhl-5HISEHLL.js +35 -0
- package/dist/qmjhl-DA3UHKE5.js +125 -0
- package/dist/qmjhl-YG4BOQPF.js +614 -0
- package/dist/react.d.ts +101 -0
- package/dist/react.js +164 -0
- package/dist/react.js.map +1 -0
- package/dist/resolve-CQW-jzvr.d.ts +28 -0
- package/dist/selection-D4T3PkRt.d.ts +140 -0
- package/dist/shots.d.ts +364 -0
- package/dist/shots.js +85 -0
- package/dist/shots.js.map +1 -0
- package/dist/soccer-7BH3ZLVL.js +7898 -0
- package/dist/soccer-MRU25OGE.js +2636 -0
- package/dist/soccer-PF4IPPVA.js +12342 -0
- package/dist/surfaces-ChaD9nei.d.ts +38 -0
- package/dist/testing.d.ts +75 -0
- package/dist/testing.js +240 -0
- package/dist/testing.js.map +1 -0
- package/dist/tiers-B6pUBPcE.d.ts +93 -0
- package/dist/types-C7n6gJF3.d.ts +62 -0
- package/dist/ufl-7VLZTLTT.js +143 -0
- package/dist/ufl-CCMBLPSW.js +100 -0
- package/dist/ufl-F2BJRXME.js +16 -0
- package/dist/usfl-4CR5RMZJ.js +32 -0
- package/dist/usfl-LD7HP2YK.js +14 -0
- package/dist/usfl-VU4YSJQD.js +19 -0
- package/dist/ushl-CPG2LB4L.js +73 -0
- package/dist/ushl-PQL6HJAD.js +88 -0
- package/dist/ushl-ZT2ATPHK.js +22 -0
- package/dist/vega.d.ts +85 -0
- package/dist/vega.js +362 -0
- package/dist/vega.js.map +1 -0
- package/dist/wbb-3KHCDYTK.js +5087 -0
- package/dist/wbb-BMX53Q3Q.js +369 -0
- package/dist/wbb-QNRN2CJJ.js +3620 -0
- package/dist/whl-4ATYZWUB.js +97 -0
- package/dist/whl-RVFPL6MH.js +79 -0
- package/dist/whl-Y7ASKYMM.js +28 -0
- package/dist/wnba-FWX5SFVI.js +232 -0
- package/dist/wnba-GX6HZR4B.js +220 -0
- package/dist/wnba-NTMWIPP6.js +20 -0
- package/dist/xfl-43R2JMJK.js +30 -0
- package/dist/xfl-H3BXQPZZ.js +16 -0
- package/dist/xfl-XON7A7FI.js +69 -0
- package/package.json +154 -3
package/README.md
CHANGED
|
@@ -1,3 +1,909 @@
|
|
|
1
|
-
#
|
|
1
|
+
# **@sportsdataverse/sdvplot** <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
|
-
|
|
3
|
+
<!-- badges: start -->
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@sportsdataverse/sdvplot)
|
|
6
|
+
[](https://www.npmjs.com/package/@sportsdataverse/sdvplot)
|
|
7
|
+
[](https://www.npmjs.com/package/@sportsdataverse/sdvplot)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
[](https://www.npmjs.com/package/@sportsdataverse/sdvplot?activeTab=code)
|
|
10
|
+
[](https://github.com/sportsdataverse/sdvplot-js/actions/workflows/ci.yml)
|
|
11
|
+
[](https://github.com/sportsdataverse/sdvplot-js/actions/workflows/release.yml)
|
|
12
|
+
[](https://plot.sportsdataverse.org)
|
|
13
|
+
[](https://lifecycle.r-lib.org/articles/stages.html#experimental)
|
|
14
|
+
[](https://github.com/sportsdataverse/sdvplot-js/blob/main/LICENSE)
|
|
15
|
+
[](https://github.com/sportsdataverse/sdvplot-js/graphs/contributors)
|
|
16
|
+
[](https://x.com/SportsDataverse)
|
|
17
|
+
|
|
18
|
+
<!-- badges: end -->
|
|
19
|
+
|
|
20
|
+
Team identity, colors, logos, wordmarks and headshots for SportsDataverse plots. TypeScript port of the Python `sdvplot` package (and `sdvplotR`).
|
|
21
|
+
|
|
22
|
+
Part of [sdvplot-js](https://github.com/sportsdataverse/sdvplot-js#readme) · [Documentation](https://plot.sportsdataverse.org) · [API reference](https://plot.sportsdataverse.org/api/sdvplot/) ·
|
|
23
|
+
[Gallery](https://plot.sportsdataverse.org/gallery/sdvplot/) · [Notebooks](https://plot.sportsdataverse.org/notebooks/) · [Source](https://github.com/sportsdataverse/sdvplot-js/tree/main/packages/sdvplot)
|
|
24
|
+
|
|
25
|
+
## **Installation**
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install @sportsdataverse/sdvplot
|
|
29
|
+
# or
|
|
30
|
+
pnpm add @sportsdataverse/sdvplot
|
|
31
|
+
# or
|
|
32
|
+
yarn add @sportsdataverse/sdvplot
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
ESM only, Node >= 20.18.1. The core and the `bins`, `testing`, `plotly`, `vega` and `echarts` subpaths need no
|
|
36
|
+
peer. The others use optional peers, which npm and pnpm do not install for you, so add the ones for the subpaths you
|
|
37
|
+
import: `react` >= 18 for `/react`, `@observablehq/plot` >= 0.6.16 for `/plot`, `d3` >= 7 for `/d3` and `/interact`,
|
|
38
|
+
`chart.js` >= 4.4 for `/chartjs` and `/chartjs/surface`, and `@resvg/resvg-js` for `/export`'s `toPNG`.
|
|
39
|
+
|
|
40
|
+
`/plot`, `/d3`, `/shots` and `/chartjs/surface` also need `@sportsdataverse/sporty`, whether or not you draw a
|
|
41
|
+
surface: they import it when they load, and fail to load without it. No other subpath imports sporty. For a Plot chart:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npm install @sportsdataverse/sdvplot @sportsdataverse/sporty @observablehq/plot
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The table under [Subpaths](#subpaths) lists each subpath's peers.
|
|
48
|
+
|
|
49
|
+
## **Quick start**
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { resolve, palette, logoUrl } from "@sportsdataverse/sdvplot";
|
|
53
|
+
|
|
54
|
+
const id = await resolve("KC", "nfl", { season: 2023 }); // canonical TeamId
|
|
55
|
+
const colors = await palette("nfl", ["KC"]); // { KC: "#e31837" }
|
|
56
|
+
const url = await logoUrl("KC", "nfl"); // CDN logo URL
|
|
57
|
+
// React: import { TeamLogo } from "@sportsdataverse/sdvplot/react"; <TeamLogo team="KC" league="nfl" size={32} />
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A shot chart with Observable Plot (needs `@observablehq/plot` and `@sportsdataverse/sporty`, both optional peers):
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import * as Plot from "@observablehq/plot";
|
|
64
|
+
import { loadLeague } from "@sportsdataverse/sdvplot";
|
|
65
|
+
import { logos, surface, teamColor } from "@sportsdataverse/sdvplot/plot";
|
|
66
|
+
import { toSurfaceFrame } from "@sportsdataverse/sporty";
|
|
67
|
+
|
|
68
|
+
await loadLeague("nba");
|
|
69
|
+
|
|
70
|
+
// stats.nba.com shots (shotchartdetail LOC_X/LOC_Y as x_legacy/y_legacy, the frame's defaults): tenths of a foot
|
|
71
|
+
// from the hoop. Two real ones, Lakers at Nuggets on 2023-10-24 (game 0022300061, events 510 and 530).
|
|
72
|
+
// Every shot lands on the -x half, so draw the defensive half only: displayRange "defense".
|
|
73
|
+
const rawShots = [
|
|
74
|
+
{ x_legacy: -53, y_legacy: 285, team: "LAL", made: true },
|
|
75
|
+
{ x_legacy: -136, y_legacy: 214, team: "DEN", made: true },
|
|
76
|
+
];
|
|
77
|
+
const shots = toSurfaceFrame(rawShots, { from: "nba-legacy" });
|
|
78
|
+
const court = surface("nba", { team: "DEN", displayRange: "defense" });
|
|
79
|
+
Plot.plot({
|
|
80
|
+
...court.scales,
|
|
81
|
+
width: 940,
|
|
82
|
+
color: teamColor("nba", { values: shots.map((s) => s.team) }),
|
|
83
|
+
marks: [
|
|
84
|
+
...court.marks,
|
|
85
|
+
Plot.dot(shots, { x: "surface_x", y: "surface_y", fill: "team", r: 5 }),
|
|
86
|
+
logos(shots, { league: "nba", x: "surface_x", y: "surface_y", team: "team", height: 0.08 }),
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`resolveSync`, `teamColorsSync`, `logoUrlSync` and `selectMarkSync` are available once `loadLeague(league)` (or `preloadAll()`) has run.
|
|
92
|
+
`headshotUrl` is sync; gsis ids additionally need `loadGsis()` (or `preloadAll()`) first — the nflverse map is its own ~3 MB chunk, loaded only on demand. Mark rows never store `archive_url`: it is derived from `sha256` + `ext` at load time, so only the content-addressed CDN URL can ever reach a page.
|
|
93
|
+
|
|
94
|
+
## **Two-team colours**
|
|
95
|
+
|
|
96
|
+
`matchupColors(teamA, teamB, { league })` picks colours that tell two teams apart on one chart (Game on Paper's "game colours"). It returns one `[teamA, teamB]` pair per theme: each colour reads at WCAG 2.5:1 or better on that theme's background, and the two are at least 20 apart in CIEDE2000. The primaries are kept whenever they work; otherwise teamB's secondary is tried, then teamA's, then both, and only then is a colour's lightness moved.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { matchupColors } from "@sportsdataverse/sdvplot";
|
|
100
|
+
|
|
101
|
+
const { light, dark } = await matchupColors("Alabama", "Georgia", { league: "cfb" });
|
|
102
|
+
// light: ["#9e1b32", "#2c2a29"] (Georgia's crimson is too close to Alabama's)
|
|
103
|
+
// dark: ["#ffffff", "#ba0c2f"] (Alabama's crimson does not read on the dark background)
|
|
104
|
+
const [home, away] = window.matchMedia("(prefers-color-scheme: dark)").matches ? dark : light;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The backgrounds default to `#ffffff` (light) and `#181a1b` (dark); pass `theme: { light, dark }` for your own. `matchupColorsSync` is the same once the league is loaded. Parity: the pairs match Game on Paper's `pickGameColors` exactly on 69 real college-football matchups (`fixtures/matchup-colors`).
|
|
108
|
+
|
|
109
|
+
## **Subpaths**
|
|
110
|
+
|
|
111
|
+
| Import | Contents |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `@sportsdataverse/sdvplot` | `resolve`, `suggest`, `teams`, `rowsFrom`, `palette`, `teamColors`, `matchupColors`, `logoUrl`, `marks` (`full: true` fetches the whole manifest lazily), `selectMark`, `selectMarkSync`, `place`, `placeSync`, `prepareTiers`, `headshotUrl`, `loadGsis`, contrast helpers (`hex6`, `luminance`, `contrast`, `onColor`, `mix`, `solid`), the selection store `createSelection` with `focusIds`, `toId`, `sameIds`, `sameCursor` (see [Linked interactivity](#linked-interactivity)), `versions`, errors, types |
|
|
114
|
+
| `@sportsdataverse/sdvplot/react` | `TeamLogo`, `Wordmark`, `Headshot`, `useTeamColors`, `useResolve`, `useSelection` (re-renders on every change of a selection store) (React >= 18, optional peer) |
|
|
115
|
+
| `@sportsdataverse/sdvplot/plot` | Observable Plot marks and scales: `logos`, `wordmarks`, `headshots`, `axisLogos`, `teamColor`/`teamFill`, `meanLines`/`medianLines`, `titleImage`, `teamTiers`, `surface`, shot-chart marks `shotCells`, `shotZones`, `shootingSignature`, `linkIds` (stamps any mark's link ids; the image marks take an `id` option) (needs `@observablehq/plot` and `@sportsdataverse/sporty` installed to import) |
|
|
116
|
+
| `@sportsdataverse/sdvplot/d3` | `appendLogos`, `appendWordmarks`, `appendHeadshots` (circular faces: `clip`, `ring`, `placeholder`), `teamColorScale`, `appendSurface`, shot-chart `appendLegend` and `appendSignature` (needs `d3` and `@sportsdataverse/sporty` installed to import) |
|
|
117
|
+
| `@sportsdataverse/sdvplot/bins` | Dependency-free x/y binning for any data: `hexbin` and `hexagonPath` (a d3-hexbin port), `squarebin` and `squarePath`, `binner` (hexagons, squares, or equal-area squares from one options object), `cellPath`, `cellPoints` |
|
|
118
|
+
| `@sportsdataverse/sdvplot/shots` | Shot-chart data and colour, no Plot or d3: the `./bins` binners, `diffScale`, `binShots`, `leagueIndex`, `cellsVsLeague`, `cellsVsDistance`, `shrunkDiff`, `LEAGUE_PRIOR_ATTEMPTS`, `sizeCells`, `statsByZone`, `fgPctByDistance`, `vsLeague`, `statsBySide`, `signaturePoints` (needs `@sportsdataverse/sporty` installed to import; `./bins` has the binners without it) |
|
|
119
|
+
| `@sportsdataverse/sdvplot/chartjs` | Chart.js 4: `logoPoints`, `wordmarkPoints`, `headshotPoints`, `pointImages`, `axisLogos`, `logoWatermarks`, `teamColor`/`teamFill` (optional peer `chart.js` >= 4.4; no sporty needed) |
|
|
120
|
+
| `@sportsdataverse/sdvplot/chartjs/surface` | Chart.js 4 court, field or rink background: `surface` (needs `@sportsdataverse/sporty` installed to import; optional peer `chart.js` >= 4.4) |
|
|
121
|
+
| `@sportsdataverse/sdvplot/export` | Node only: `toPNG` (SVG to PNG; optional peer `@resvg/resvg-js`), `socialCard` (fixed-ratio framing, `gt_social_crop`), `svgSize`, `parseAspect`, `parseGravity`, `checkColor`, `canvasFor`, `offsetFor`, `peerMissing` |
|
|
122
|
+
| `@sportsdataverse/sdvplot/interact` | Linked figures over a selection store: `brushFilter` (a d3-brush overlay behind the marks writes the brushed ids and region; on a figure with a Plot `tip`, the press that starts a brush also pins the tip showing at that moment), `highlight`, `linkSelection` (one figure or one sdvtables table per call: hover and selection both ways, and a brush filters a linked table; a Plot `tip` hovers through `hover: { id }`), `linkCursor` (the store's `cursor`, one shared hover value such as a shot distance, drawn and emitted through each figure's own scale as a rule, a band or a ring around the hoop; unrelated to sdvtables' keyboard `TableCursor`), `nearestHover` (the nearest mark of a d3 or other non-Plot figure within a radius writes `hover`), `tooltip` (an in-SVG tooltip box for non-Plot figures), `hasDom` (optional peer `d3`; inert without a DOM) |
|
|
123
|
+
| `@sportsdataverse/sdvplot/testing` | Adapter-contract suite for renderer adapters: `checkAdapterContract`, `drawnMarks`, `drawnAxisMarks`, `visibleAxisLabels` |
|
|
124
|
+
| `@sportsdataverse/sdvplot/plotly` | `withLogos`, `withWordmarks`, `withHeadshots`, `withAxisLogos`, `teamColorway`, `embedSources` (no runtime dependency) |
|
|
125
|
+
| `@sportsdataverse/sdvplot/vega` | `withLogos`, `withWordmarks`, `withHeadshots`, `logoLayer`, `withAxisLogos`, `teamColorScale`, `embedSources` (no runtime dependency) |
|
|
126
|
+
| `@sportsdataverse/sdvplot/echarts` | `withLogos`, `withWordmarks`, `withHeadshots`, `withAxisLogos`, `teamColorPalette`, `embedSources` (no runtime dependency) |
|
|
127
|
+
|
|
128
|
+
## **Observable Plot: Plot's own options, transforms and tips**
|
|
129
|
+
|
|
130
|
+
The `sdvplot/plot` marks compute only what Plot cannot: each row's image URL, its aspect, its size as a fraction of
|
|
131
|
+
the frame, and skip-and-warn for a team that does not resolve. Everything else is Observable Plot's. The image marks
|
|
132
|
+
(`logos`, `wordmarks`, `headshots`) take every `Plot.image` option except `src`, `width`, `height`, `r` and
|
|
133
|
+
`preserveAspectRatio`: `x` and `y` (a field name, an accessor or an array), `tip`, `href` and `target`, `title`, `fx`
|
|
134
|
+
and `fy`, `sort`, `filter` and `reverse`, `dx` and `dy`, `className`, `clip`, `opacity` and `channels`. `axisLogos`,
|
|
135
|
+
`meanLines` and `medianLines` take Plot's options too, `teamTiers` takes `tip`, and the shot marks take both
|
|
136
|
+
([Shot charts](#shot-charts)).
|
|
137
|
+
|
|
138
|
+
- Plot's row-preserving transforms wrap the image marks: `Plot.dodgeY` and `Plot.dodgeX` (a logo beeswarm),
|
|
139
|
+
`Plot.stackY`, `Plot.windowY`, `Plot.selectLast` and `Plot.pointer`. A transform that makes new rows (`Plot.bin`,
|
|
140
|
+
`Plot.group`, `Plot.hexbin`) throws `InputError`, because one image per input row cannot survive it: aggregate the
|
|
141
|
+
rows first, then draw the result.
|
|
142
|
+
- `height` stays a fraction of the frame, or of each facet's frame under `fx` or `fy`.
|
|
143
|
+
- Column-name `x` and `y` label the axes, as on any Plot mark (`x: "wins"` gives `wins →`); pass
|
|
144
|
+
`x: { label: null }` to `Plot.plot` to hide one.
|
|
145
|
+
- Under a dodge, `r` is the collision radius in pixels. Half the drawn height makes neighbours just touch; a smaller
|
|
146
|
+
`r` lets them overlap. It never clips or sizes an image. Plot's own image mark uses `r` to clip the image to a
|
|
147
|
+
circle, while sdvplot uses `r` only for dodge spacing and never clips.
|
|
148
|
+
- `alpha` is the Python sdvplot name for a constant `opacity`, kept for parity; Plot's `opacity` also takes a per-row
|
|
149
|
+
channel. Passing both throws `InputError`.
|
|
150
|
+
- A `render`, `transform` or `initializer` you pass is composed with sdvplot's, never replaced: your `render` sees the
|
|
151
|
+
sized image, and `Plot.pointer` stays outermost.
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import * as Plot from "@observablehq/plot";
|
|
155
|
+
import { loadLeague } from "@sportsdataverse/sdvplot";
|
|
156
|
+
import { logos, meanLines } from "@sportsdataverse/sdvplot/plot";
|
|
157
|
+
|
|
158
|
+
await loadLeague("nfl");
|
|
159
|
+
// The 2024 AFC West and East (regular season): wins and points for from the repo's STANDINGS sample
|
|
160
|
+
// (packages/sdvtables/test/fixtures/standings.ts, nflverse games.csv); net EPA per rush or pass play, offence minus
|
|
161
|
+
// defence, from fixtures/examples/nfl_epa_2024_reg.csv (nflverse play_by_play_2024)
|
|
162
|
+
const afc = [
|
|
163
|
+
{ team: "KC", division: "West", wins: 15, pf: 385, net_epa: 0.063 },
|
|
164
|
+
{ team: "LAC", division: "West", wins: 11, pf: 402, net_epa: 0.101 },
|
|
165
|
+
{ team: "DEN", division: "West", wins: 10, pf: 425, net_epa: 0.108 },
|
|
166
|
+
{ team: "LV", division: "West", wins: 4, pf: 309, net_epa: -0.146 },
|
|
167
|
+
{ team: "BUF", division: "East", wins: 13, pf: 525, net_epa: 0.19 },
|
|
168
|
+
{ team: "MIA", division: "East", wins: 8, pf: 345, net_epa: -0.019 },
|
|
169
|
+
{ team: "NYJ", division: "East", wins: 5, pf: 338, net_epa: -0.045 },
|
|
170
|
+
{ team: "NE", division: "East", wins: 4, pf: 289, net_epa: -0.162 },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
// A beeswarm: the frame is 150 - 20 - 30 = 100 px, so each logo is 0.2 x 100 = 20 px tall and r = 10 px touches
|
|
174
|
+
const beeswarm = Plot.plot({
|
|
175
|
+
height: 150,
|
|
176
|
+
marginTop: 20,
|
|
177
|
+
marginBottom: 30,
|
|
178
|
+
marks: [logos(afc, Plot.dodgeY({ league: "nfl", team: "team", x: "net_epa", r: 10, height: 0.2 }))],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Small multiples: mark-level fx, one facet per division, each with its own mean
|
|
182
|
+
const facets = Plot.plot({
|
|
183
|
+
marks: [
|
|
184
|
+
...meanLines(afc, { x: "wins", fx: "division" }),
|
|
185
|
+
logos(afc, { league: "nfl", x: "wins", y: "pf", team: "team", fx: "division", height: 0.15 }),
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Tips and the figure's value
|
|
191
|
+
|
|
192
|
+
`tip: true` adds Plot's own tip, opt-in as everywhere in Plot. The default channels are the team, named as its image
|
|
193
|
+
is ("KC" for rows keyed by `team_id` too; the player id for headshots), with `x` and `y` on the image marks; attempts, FG%, league FG% and the shrunk difference on `shotCells`;
|
|
194
|
+
the zone, and given `stats` its makes/attempts and FG%, on `shotZones`; distance, FG%, league FG% and shot share on
|
|
195
|
+
`shootingSignature`. A tip object's `format` overrides sdvplot's formats key by key. What is under the pointer is the
|
|
196
|
+
figure's `value`, with an `input` event on each change, as for any Plot mark. A server-rendered figure carries one
|
|
197
|
+
empty tip group, inert until the page runs the chart.
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
// Hover a logo for its team, wins and points for; each logo also links to its team's page
|
|
201
|
+
const fig = Plot.plot({
|
|
202
|
+
marks: [
|
|
203
|
+
logos(afc, {
|
|
204
|
+
league: "nfl",
|
|
205
|
+
x: "wins",
|
|
206
|
+
y: "pf",
|
|
207
|
+
team: "team",
|
|
208
|
+
tip: true,
|
|
209
|
+
href: (d) => `https://www.espn.com/nfl/team/_/name/${d.team.toLowerCase()}`,
|
|
210
|
+
target: "_blank",
|
|
211
|
+
}),
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
fig.addEventListener("input", () => console.log(fig.value?.team)); // "KC" while KC's logo is pointed at
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Accessible names
|
|
218
|
+
|
|
219
|
+
Every image an SVG renderer draws is named by its subject: "KC logo", "KC wordmark", "3139477 headshot". Logos and
|
|
220
|
+
wordmarks are named by the resolved team whatever id system the rows use (`team_id` included), on the Plot image marks
|
|
221
|
+
and `axisLogos`, the Vega image layers and d3's `appendLogos`, `appendWordmarks` and `appendHeadshots`. On the Plot
|
|
222
|
+
marks `ariaLabel` is Plot's per-image channel, so a string is a column name (`ariaLabel: "qb"`) and an accessor or an
|
|
223
|
+
array gives any other text; the d3 helpers take `ariaLabel: (value, i) => text`. `shotZones` names each path by its
|
|
224
|
+
zone and `shotCells` each cell by its makes, attempts and FG% ("147 of 181 made, 81.2%"), the shooting signature's
|
|
225
|
+
ribbon is named in Plot and d3, `appendLegend` names its colour bar, a titled `teamTiers` figure is labelled by its
|
|
226
|
+
title, and `ariaDescription` passes through to a mark's group. sporty describes every surface it draws ("nba
|
|
227
|
+
basketball surface"; `ariaDescription` replaces it): `surfaceMark`, `toSVG` and d3's `appendSurface`, sdvplot's
|
|
228
|
+
included. Plotly, ECharts and Chart.js draw to a canvas or to layout images, so
|
|
229
|
+
the chart is named through each library (see each adapter below).
|
|
230
|
+
|
|
231
|
+
## **Spec adapters (Plotly, Vega-Lite, ECharts) — zero runtime deps**
|
|
232
|
+
|
|
233
|
+
These patch a plain spec object and never import the charting library: use whatever plotly.js / vega-embed / echarts
|
|
234
|
+
build you already load. Each verb returns a NEW spec and leaves its input alone. The verbs are synchronous, so load the
|
|
235
|
+
team data first (`preloadAll()` or `loadLeague(league)`).
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import * as Plotly from "plotly.js"; // any build: plotly.js-dist-min works the same
|
|
239
|
+
import { preloadAll } from "@sportsdataverse/sdvplot";
|
|
240
|
+
import { withLogos } from "@sportsdataverse/sdvplot/plotly";
|
|
241
|
+
|
|
242
|
+
await preloadAll();
|
|
243
|
+
// 2024 regular-season points scored and allowed: two rows of the repo's real STANDINGS sample
|
|
244
|
+
// (packages/sdvtables/test/fixtures/standings.ts, from nflverse games.csv)
|
|
245
|
+
const rows = [
|
|
246
|
+
{ team: "KC", pf: 385, pa: 326 },
|
|
247
|
+
{ team: "BUF", pf: 525, pa: 368 },
|
|
248
|
+
];
|
|
249
|
+
// TypeScript: type the figure as plotly.js's own and the same type comes back for Plotly.newPlot (JS needs no type)
|
|
250
|
+
const base: { data: Plotly.Data[]; layout: Partial<Plotly.Layout> } = {
|
|
251
|
+
data: [{ type: "scatter", mode: "markers", x: rows.map((r) => r.pf), y: rows.map((r) => r.pa) }],
|
|
252
|
+
layout: {},
|
|
253
|
+
};
|
|
254
|
+
const fig = withLogos(base, rows, { x: "pf", y: "pa", team: "team", league: "nfl", height: 0.12 });
|
|
255
|
+
await Plotly.newPlot(document.getElementById("chart")!, fig.data, fig.layout);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Vega-Lite's `TopLevelSpec` and echarts' `EChartsOption` come back as themselves the same way.
|
|
259
|
+
|
|
260
|
+
Options every adapter takes (`withLogos` / `withWordmarks` / `withHeadshots` are the mark verbs):
|
|
261
|
+
|
|
262
|
+
| Option | Verbs | Meaning |
|
|
263
|
+
| --- | --- | --- |
|
|
264
|
+
| `x`, `y` | marks | Row columns holding each image's position |
|
|
265
|
+
| `team` | `withLogos`, `withWordmarks` | Row column holding the team (abbreviation, name or id) |
|
|
266
|
+
| `player` | `withHeadshots` | Row column holding the player id |
|
|
267
|
+
| `league` | all | `"nfl"`, `"nba"`, `"cfb"`, ... |
|
|
268
|
+
| `season` | logos, wordmarks, `withAxisLogos` | A season; for the mark verbs also the name of a row column holding each row's season |
|
|
269
|
+
| `height` | all | Image height as a fraction of the plot area's height, in (0, 1]; default 0.1 |
|
|
270
|
+
| `alpha` | marks | Opacity in [0, 1]; default 1 |
|
|
271
|
+
| `variant` | logos, wordmarks, `withAxisLogos` | Logo variant, e.g. `"dark"` |
|
|
272
|
+
| `idSystem` | all | How the team values are read (default `"auto"`); for headshots `"espn"` (default) or `"gsis"` (needs `loadGsis()`) |
|
|
273
|
+
| `markType` | `withAxisLogos` | `"logo"` (default) or `"wordmark"` |
|
|
274
|
+
| `embed` | all | `await embedSources(urls)` (each subpath exports it): inlines the images as data URIs (offline HTML, static export) |
|
|
275
|
+
|
|
276
|
+
An unknown team or player is skipped with one warning per call. The colour helpers (`teamColorway`, `teamColorScale`,
|
|
277
|
+
`teamColorPalette`) take `(league, teams, { which, season, idSystem, fallback })`: `which` is `"primary"` (default) or
|
|
278
|
+
`"secondary"`, and an unknown team gets `fallback` (default `"#808080"`).
|
|
279
|
+
|
|
280
|
+
**Plotly** (`layout.images`):
|
|
281
|
+
|
|
282
|
+
| Option | Verbs | Meaning |
|
|
283
|
+
| --- | --- | --- |
|
|
284
|
+
| `xref`, `yref` | all | The subplot's axes, e.g. `"x2"`, `"y2"`; default `"x"`, `"y"` |
|
|
285
|
+
| `layer` | marks | `"above"` (default) or `"below"` the traces |
|
|
286
|
+
|
|
287
|
+
`height` is a fraction of the subplot. The mark verbs PIN the axis ranges (with half a mark of room), because a
|
|
288
|
+
data-placed image is sized in axis units; set `layout.xaxis.range` yourself to keep your own. `withAxisLogos` sizes
|
|
289
|
+
margins in pixels from `layout.width`/`layout.height` (Plotly's 700 × 450 when unset). x-axis images hang under their
|
|
290
|
+
subplot and `margin.b` grows by what reaches below the figure; under an upper subplot they hang into the one below.
|
|
291
|
+
Accessible name: plotly.js has no accessibility option and layout images carry no name, so name the chart on the
|
|
292
|
+
element you pass to `Plotly.newPlot` (`role="img"` and an `aria-label`).
|
|
293
|
+
|
|
294
|
+
**Vega-Lite** (a native `image` layer):
|
|
295
|
+
|
|
296
|
+
| Option | Verbs | Meaning |
|
|
297
|
+
| --- | --- | --- |
|
|
298
|
+
| `chartHeight` | `logoLayer` | The chart height in px that `height` is a fraction of; default 300 |
|
|
299
|
+
| `xType`, `yType` | `logoLayer` | The layer's encoding types (its fields are `x` and `y`); default `"quantitative"` |
|
|
300
|
+
|
|
301
|
+
The verbs size from the chart's own `height` (else `config.view.continuousHeight`, else 300 px); a discrete y axis needs
|
|
302
|
+
`height`. A discrete `sort` that Vega-Lite drops once a layer is added raises: sort with a list (`sort: [...]`). The
|
|
303
|
+
shorthand `"-y"` works where Vega-Lite keeps it; on a stacked bar or area it sums the measure, which Vega-Lite drops,
|
|
304
|
+
so it raises too. A `count` sort is written out as its order (a list) on both layers, so the image rows do not add to
|
|
305
|
+
the counts; that needs inline data with no `transform`. `withAxisLogos` assumes the default axis orient (x at the
|
|
306
|
+
bottom, y on the left): with `orient: "top"` or `"right"` the images land on the opposite side.
|
|
307
|
+
|
|
308
|
+
**ECharts** (a `custom` series; axis logos as rich-text labels):
|
|
309
|
+
|
|
310
|
+
| Option | Verbs | Meaning |
|
|
311
|
+
| --- | --- | --- |
|
|
312
|
+
| `xAxisIndex`, `yAxisIndex` | marks | The axes (and so the grid) the series draws on; default 0. Calls on other axes (or another `z`) get their own series |
|
|
313
|
+
| `z` | marks | The series' z; default 100 |
|
|
314
|
+
| `axisIndex` | `withAxisLogos` | Which x (or y) axis, for an option with several; default 0 |
|
|
315
|
+
| `chartHeight` | `withAxisLogos` | The canvas height in px (an option has none); default 400 |
|
|
316
|
+
|
|
317
|
+
Mark images are `height` × the grid's height at render time (they follow zoom and resize; no instance needed).
|
|
318
|
+
Axis images are `height` of the axis' grid on a `chartHeight` px canvas: `grid.height`, else `chartHeight` less
|
|
319
|
+
`grid.top` and `grid.bottom` (ECharts' 65 and 80 px when unset); `containLabel` can shrink the drawn grid a little
|
|
320
|
+
below that. The helper series have no `name`, so a default `legend: {}` lists only your series. Accessible name:
|
|
321
|
+
`aria: { enabled: true, label: { description: "…" } }` gives the chart's container `role="img"` and that `aria-label`
|
|
322
|
+
(in the browser; server-side rendering writes none); without a `description` ECharts writes one from every series,
|
|
323
|
+
the logo series too.
|
|
324
|
+
|
|
325
|
+
## **Chart.js (Astro, Svelte, React, plain scripts)**
|
|
326
|
+
|
|
327
|
+
`@sportsdataverse/sdvplot/chartjs` returns Chart.js 4 dataset options and plugin objects, so no framework needs a
|
|
328
|
+
wrapper. Load the league first, and build point styles and plugins in the browser (they create `<img>`/`<canvas>`), or
|
|
329
|
+
pass `loadImage` to render in Node ([below](#server-side-rendering-node)).
|
|
330
|
+
|
|
331
|
+
An Astro page with a Svelte 5 island (Game on Paper's stack):
|
|
332
|
+
|
|
333
|
+
```astro
|
|
334
|
+
---
|
|
335
|
+
// src/pages/teams.astro
|
|
336
|
+
import TeamScatter from "../components/TeamScatter.svelte";
|
|
337
|
+
// 2024 points scored and allowed, e.g. [{ team: "KC", pf: 385, pa: 326 }, { team: "BUF", pf: 525, pa: 368 }, …]
|
|
338
|
+
// (real rows: the repo's STANDINGS sample, packages/sdvtables/test/fixtures/standings.ts)
|
|
339
|
+
const rows = await getTeamRows();
|
|
340
|
+
---
|
|
341
|
+
<TeamScatter client:only="svelte" rows={rows} />
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```svelte
|
|
345
|
+
<!-- src/components/TeamScatter.svelte -->
|
|
346
|
+
<script lang="ts">
|
|
347
|
+
import Chart from "chart.js/auto";
|
|
348
|
+
import { loadLeague } from "@sportsdataverse/sdvplot";
|
|
349
|
+
import { logoPoints, pointImages } from "@sportsdataverse/sdvplot/chartjs";
|
|
350
|
+
|
|
351
|
+
let { rows }: { rows: { team: string; pf: number; pa: number }[] } = $props();
|
|
352
|
+
let canvas: HTMLCanvasElement;
|
|
353
|
+
|
|
354
|
+
$effect(() => { // runs in the browser only, never during SSR
|
|
355
|
+
// read `rows` here, synchronously: Svelte 5 tracks only what an effect reads before it awaits, so this re-runs on a new `rows`
|
|
356
|
+
const data = rows.map((r) => ({ x: r.pf, y: r.pa }));
|
|
357
|
+
const teams = rows.map((r) => r.team);
|
|
358
|
+
let chart: Chart | undefined;
|
|
359
|
+
let live = true;
|
|
360
|
+
loadLeague("nfl").then(() => {
|
|
361
|
+
if (!live) return;
|
|
362
|
+
chart = new Chart(canvas, {
|
|
363
|
+
type: "scatter",
|
|
364
|
+
data: { datasets: [{ data, ...logoPoints(teams, { league: "nfl", radius: 14 }) }] },
|
|
365
|
+
plugins: [pointImages],
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
return () => { live = false; chart?.destroy(); };
|
|
369
|
+
});
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<canvas bind:this={canvas}></canvas>
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
The line charts below draw Super Bowl LIX (Kansas City at Philadelphia, 2025-02-09). `wp` is ESPN's win probability
|
|
376
|
+
for Philadelphia, the home team, after each of the game's 186 plays, against minutes played:
|
|
377
|
+
`[{ minute: 0, home_wp: 0.5846 }, …, { minute: 60, home_wp: 1 }]`. These are the repo's real `SUPER_BOWL_LIX_WP` rows
|
|
378
|
+
(`examples/src/data.ts`, from ESPN Site v2 `summary?event=401671889`).
|
|
379
|
+
|
|
380
|
+
A line per team with its logo at the line's end (the per-point logos of Game on Paper's trends chart, team colours from
|
|
381
|
+
sdvplot):
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
const series = {
|
|
385
|
+
PHI: wp.map((p) => ({ x: p.minute, y: p.home_wp })),
|
|
386
|
+
KC: wp.map((p) => ({ x: p.minute, y: 1 - p.home_wp })),
|
|
387
|
+
};
|
|
388
|
+
new Chart(canvas, {
|
|
389
|
+
type: "scatter",
|
|
390
|
+
data: {
|
|
391
|
+
datasets: Object.entries(series).map(([team, pts]) => ({
|
|
392
|
+
label: team,
|
|
393
|
+
data: pts,
|
|
394
|
+
showLine: true,
|
|
395
|
+
borderWidth: 3,
|
|
396
|
+
borderColor: teamColor(team, "nfl"),
|
|
397
|
+
backgroundColor: teamColor(team, "nfl", { alpha: 0.5 }),
|
|
398
|
+
pointStyle: logoPoints([team], { league: "nfl", radius: 14 }).pointStyle, // one style, repeated per point
|
|
399
|
+
pointRadius: pts.map((_, i) => (i === pts.length - 1 ? 14 : 0)), // drawn only at the line's end
|
|
400
|
+
})),
|
|
401
|
+
},
|
|
402
|
+
plugins: [pointImages],
|
|
403
|
+
});
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
A radar in team colours (the datasets Game on Paper's `utils/radar.ts` builds for `TeamRadarChart.svelte` and
|
|
407
|
+
`MatchupRadarChart.svelte`):
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { teamColor, teamFill } from "@sportsdataverse/sdvplot/chartjs";
|
|
411
|
+
// 2024 regular season (17 games): two rows of the repo's real STANDINGS sample
|
|
412
|
+
// (packages/sdvtables/test/fixtures/standings.ts, from nflverse games.csv)
|
|
413
|
+
const rows = [
|
|
414
|
+
{ team: "KC", wins: 15, pf: 385, pa: 326 },
|
|
415
|
+
{ team: "BUF", wins: 13, pf: 525, pa: 368 },
|
|
416
|
+
];
|
|
417
|
+
const data = {
|
|
418
|
+
labels: ["Wins", "Points per game", "Allowed per game"],
|
|
419
|
+
datasets: rows.map(({ team, wins, pf, pa }) => ({
|
|
420
|
+
label: team,
|
|
421
|
+
data: [wins, pf / 17, pa / 17],
|
|
422
|
+
fill: true,
|
|
423
|
+
backgroundColor: teamFill(team, "nfl"), // rgba(r, g, b, 0.2)
|
|
424
|
+
borderColor: teamColor(team, "nfl"),
|
|
425
|
+
pointBackgroundColor: teamColor(team, "nfl"),
|
|
426
|
+
pointBorderColor: "#fff", // radar.ts:70-80 rings each point in white
|
|
427
|
+
pointHoverBackgroundColor: "#fff",
|
|
428
|
+
pointHoverBorderColor: teamColor(team, "nfl"),
|
|
429
|
+
})),
|
|
430
|
+
};
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Faint team logos behind a line (Game on Paper's win-probability chart). Pass `[home, away]`: the line is the home win
|
|
434
|
+
probability, so the home logo sits top-left of the chart area and the away logo bottom-left, at 0.4 opacity and 75 px
|
|
435
|
+
tall by default:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { logoWatermarks, teamColor } from "@sportsdataverse/sdvplot/chartjs";
|
|
439
|
+
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
|
|
440
|
+
new Chart(canvas, {
|
|
441
|
+
type: "line",
|
|
442
|
+
data: {
|
|
443
|
+
datasets: [{ data: series.PHI, borderColor: teamColor("PHI", "nfl"), pointRadius: 0 }], // series: above
|
|
444
|
+
},
|
|
445
|
+
options: { scales: { x: { type: "linear" } } }, // minutes played
|
|
446
|
+
plugins: [logoWatermarks(["PHI", "KC"], { league: "nfl", variant: dark ? "dark" : "default" })],
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Two teams' win-probability (or EP) lines in colours that tell them apart, per theme:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
const { light, dark: darkPair } = await matchupColors("PHI", "KC", { league: "nfl" });
|
|
454
|
+
const [home, away] = (dark ? darkPair : light);
|
|
455
|
+
// datasets: [{ label: "PHI", data: series.PHI, borderColor: home }, { label: "KC", data: series.KC, borderColor: away }]
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Logos on a category axis (2024 wins, from the repo's real STANDINGS sample):
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
new Chart(canvas, {
|
|
462
|
+
type: "bar",
|
|
463
|
+
data: { labels: ["KC", "BUF", "LAC"], datasets: [{ data: [15, 13, 11], backgroundColor: teamColor(["KC", "BUF", "LAC"], "nfl") }] },
|
|
464
|
+
plugins: [axisLogos("x", { league: "nfl", size: 28 })],
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
A shot chart over a court (and a hexbin as bubbles at the hex centres). `shots` is stats.nba.com `shotchartdetail`
|
|
469
|
+
rows with `LOC_X`/`LOC_Y` as `x_legacy`/`y_legacy` (the frame's defaults), e.g. the repo's real `NBA_SHOTS`
|
|
470
|
+
(`examples/src/data.ts`): the 38 fourth-quarter shots of the Lakers at the Nuggets, 2023-10-24.
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
import { toSurfaceFrame } from "@sportsdataverse/sporty";
|
|
474
|
+
import { surface } from "@sportsdataverse/sdvplot/chartjs/surface";
|
|
475
|
+
import { hexbin } from "d3-hexbin";
|
|
476
|
+
const court = surface("nba", { team: "DEN", displayRange: "defense" });
|
|
477
|
+
const pts = toSurfaceFrame(shots, { from: "nba-legacy" })
|
|
478
|
+
.map((s) => ({ x: s.surface_x ?? Number.NaN, y: s.surface_y ?? Number.NaN }));
|
|
479
|
+
const bins = hexbin<{ x: number; y: number }>().x((d) => d.x).y((d) => d.y).radius(1.5)(pts); // radius in feet
|
|
480
|
+
const [x0, y0, x1, y1] = court.scene.bbox;
|
|
481
|
+
new Chart(canvas, {
|
|
482
|
+
type: "bubble",
|
|
483
|
+
data: { datasets: [{ data: bins.map((b) => ({ x: b.x, y: b.y, r: 2 * Math.sqrt(b.length) })) }] },
|
|
484
|
+
options: { scales: court.scales, aspectRatio: (x1 - x0) / (y1 - y0), plugins: { legend: { display: false } } },
|
|
485
|
+
plugins: [court.plugin],
|
|
486
|
+
});
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
- Plugins are fixed when the chart is created (`new Chart`): to change the watermark teams or the court, destroy the chart and create a new one.
|
|
490
|
+
- Sizes are pixels: `radius` (point styles), `size` (axis logos, watermarks) — Chart.js draws an image at its own size.
|
|
491
|
+
- Add `pointImages` to `plugins` with any `*Points`: Chart.js does not redraw when an `<img>` finishes loading.
|
|
492
|
+
- An unknown team draws its own label as text (or pass `fallback: "circle"`), with one warning per call; the text is
|
|
493
|
+
grey on a light chart and light grey on a dark one (`background`, default white, or black with `variant: "dark"`).
|
|
494
|
+
- Dark theme: `variant: "dark"` (read `prefers-color-scheme` as Game on Paper does); a team with no dark mark falls back to a light one by polarity, so no `onerror` retry is needed.
|
|
495
|
+
- Two teams on one chart: `teamColor(team, league, { which: "secondary" })` is the alternate. For a two-team chart use `matchupColors` (above).
|
|
496
|
+
- `axisLogos` needs a category axis (any other scale is left as it is, with one warning); on `y` the axis widens to the widest mark, so wordmarks fit; unresolved labels keep their text; your own scale options are not modified, and replacing `chart.options` (`chart.options = next; chart.update()`) keeps the logos.
|
|
497
|
+
- `surface` paints before the datasets, clipped to the chart area, through the chart's own scales (so it follows
|
|
498
|
+
resizes and a reversed axis); keep both axes linear (`court.scales`; any other scale is left unpainted, with one
|
|
499
|
+
warning). An `xlim` or `ylim` of zero width throws `InputError` when the surface is built. With `logoWatermarks` on the
|
|
500
|
+
same chart, list `court.plugin` first: both paint before the datasets, in `plugins` order (the wrong order warns once).
|
|
501
|
+
`aspectRatio` sizes the canvas, not the chart area, so the axes and the legend skew the court's proportions by a few
|
|
502
|
+
percent: hide the legend (as above) and match the court's aspect through layout padding for undistorted circles. On a
|
|
503
|
+
reversed x axis the football field's yard numbers render mirrored; courts and rinks are unaffected (limitation: a
|
|
504
|
+
follow-up in sporty so `drawScene` keeps text upright). It needs
|
|
505
|
+
`@sportsdataverse/sporty`, as `sdvplot/d3` does.
|
|
506
|
+
- Destroying a chart drops its pending image listeners, so unmounting before the logos arrive is safe.
|
|
507
|
+
- Accessible name: Chart.js adds none (it draws pixels), so name the chart on the canvas: `role="img"` and an
|
|
508
|
+
`aria-label`, or fallback content between `<canvas>` and `</canvas>`.
|
|
509
|
+
|
|
510
|
+
### Server-side rendering (Node)
|
|
511
|
+
|
|
512
|
+
Pass `loadImage` and no DOM image is made: Chart.js renders on `@napi-rs/canvas` (server-rendered PNGs, social
|
|
513
|
+
cards). `@napi-rs/canvas`'s own `loadImage` fits as it is; wrap it to keep the promises, and once they settle the chart
|
|
514
|
+
is complete:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { writeFile } from "node:fs/promises";
|
|
518
|
+
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
|
519
|
+
import { Chart, registerables } from "chart.js";
|
|
520
|
+
import { loadLeague } from "@sportsdataverse/sdvplot";
|
|
521
|
+
import { logoWatermarks, teamColor } from "@sportsdataverse/sdvplot/chartjs";
|
|
522
|
+
|
|
523
|
+
Chart.register(...registerables);
|
|
524
|
+
await loadLeague("nfl");
|
|
525
|
+
const loads: Promise<unknown>[] = [];
|
|
526
|
+
const load = (url: string) => {
|
|
527
|
+
const p = loadImage(url); // fetches the CDN URL
|
|
528
|
+
loads.push(p);
|
|
529
|
+
return p;
|
|
530
|
+
};
|
|
531
|
+
const canvas = createCanvas(800, 450);
|
|
532
|
+
const chart = new Chart(canvas as unknown as HTMLCanvasElement, {
|
|
533
|
+
type: "line",
|
|
534
|
+
data: {
|
|
535
|
+
// wp: Super Bowl LIX's win probability for Philadelphia (above)
|
|
536
|
+
datasets: [{ data: wp.map((p) => ({ x: p.minute, y: p.home_wp })), borderColor: teamColor("PHI", "nfl"), pointRadius: 0 }],
|
|
537
|
+
},
|
|
538
|
+
options: { responsive: false, animation: false, scales: { x: { type: "linear" } } },
|
|
539
|
+
plugins: [logoWatermarks(["PHI", "KC"], { league: "nfl", loadImage: load })],
|
|
540
|
+
});
|
|
541
|
+
await Promise.all(loads); // each image redraws the chart as it lands, before this resumes
|
|
542
|
+
await writeFile("wp.png", canvas.toBuffer("image/png"));
|
|
543
|
+
chart.destroy();
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
- Every image helper takes it: `logoPoints`, `wordmarkPoints`, `headshotPoints`, `axisLogos`, `logoWatermarks`. Point
|
|
547
|
+
styles still need `pointImages` in `plugins`, which updates the chart as they land.
|
|
548
|
+
- `loadImage` is called once per URL and drawn size (per loader function), and the image's `width` and `height` are set
|
|
549
|
+
to that size, so resolve a new image on every call, as `@napi-rs/canvas`'s does (a loader that hands out one shared image warns once and skips the use at a different size). Await `Promise.all(loads)` after `new Chart(...)`, since `axisLogos` first calls the loader inside it.
|
|
550
|
+
- In Node an unknown team's text fallback is a circle (there is no canvas to letter it on); pass `fallback` for another
|
|
551
|
+
style. A load that fails warns once and is skipped.
|
|
552
|
+
|
|
553
|
+
## **Shot charts**
|
|
554
|
+
|
|
555
|
+
`sdvplot/shots` computes the shot charts of both blazing-the-nets apps (`main` by default, the 2021 `master` through
|
|
556
|
+
options) with no Plot or d3 import; `sdvplot/plot` and `sdvplot/d3` draw them. The input is `nba_stats_shots` release
|
|
557
|
+
rows: `x_legacy`/`y_legacy` in tenths of a foot from the hoop, `shot_distance` in feet, `shot_value` (2 or 3) and
|
|
558
|
+
`shot_result` (`"Made"` or `"Missed"`). Master-style rows (stats.nba.com `shotchartdetail`) map with one `.map`;
|
|
559
|
+
only the zones read `shot_value`:
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
const shots = rows.map((r) => ({
|
|
563
|
+
x_legacy: r.LOC_X,
|
|
564
|
+
y_legacy: r.LOC_Y,
|
|
565
|
+
shot_distance: r.SHOT_DISTANCE,
|
|
566
|
+
shot_result: r.SHOT_MADE_FLAG === 1 ? "Made" : "Missed",
|
|
567
|
+
shot_value: r.SHOT_TYPE === "3PT Field Goal" ? 3 : 2,
|
|
568
|
+
}));
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`main`'s chart, hoop at the bottom: colour is the cell's FG% against the league's in the same cell, size is attempts.
|
|
572
|
+
|
|
573
|
+
```js
|
|
574
|
+
import * as Plot from "@observablehq/plot";
|
|
575
|
+
import { shotCells, surface } from "@sportsdataverse/sdvplot/plot";
|
|
576
|
+
import { cellsVsLeague, diffScale, leagueIndex, sizeCells } from "@sportsdataverse/sdvplot/shots";
|
|
577
|
+
|
|
578
|
+
const index = leagueIndex(leagueShots, 15); // the league's season in 1.5 ft hexagons (radius in tenths of a foot)
|
|
579
|
+
const cells = cellsVsLeague(playerShots, index); // a cell under 25 league attempts takes its zone's league FG%
|
|
580
|
+
const court = surface("nba", { displayRange: "defense", rotation: 90 });
|
|
581
|
+
Plot.plot({
|
|
582
|
+
...court.scales,
|
|
583
|
+
width: 500,
|
|
584
|
+
marks: [...court.marks, shotCells(cells, { r: sizeCells(cells, index).r, frame: "nba-legacy-vertical" })],
|
|
585
|
+
});
|
|
586
|
+
Plot.legend({ color: diffScale().plot }); // the matching colour key
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Zones are `shotZones(basketballZones("nba", { scale: 10 }), { fill, text, frame: "nba-legacy-vertical" })` over
|
|
590
|
+
`statsByZone(shots)` (the shots' frame, so the zones sit under them on this court), and the
|
|
591
|
+
shooting signature is `shootingSignature(signaturePoints(vsLeague(fgPctByDistance(player), fgPctByDistance(league))))`.
|
|
592
|
+
The d3 twins are `appendLegend` (colour bar plus a cell size key) and `appendSignature`. The shot-charts guide,
|
|
593
|
+
<https://plot.sportsdataverse.org/guides/shot-charts>, draws each one.
|
|
594
|
+
|
|
595
|
+
`main` against `master`:
|
|
596
|
+
|
|
597
|
+
| Setting | `main` (default) | `master` |
|
|
598
|
+
| --- | --- | --- |
|
|
599
|
+
| League baseline | `cellsVsLeague(player, leagueIndex(league, 15))`: the league in the same cell | `cellsVsDistance(player, fgPctByDistance(league))`: the league at the cell's distance (radius 10 by default). The distance is `Math.hypot`: master's `sqrt` read 13 of BKN's 374 cells, those a whole number of feet out, one foot short |
|
|
600
|
+
| Size (`sizeCells` `rule`) | `"sqrt-p95"` | `"linear-cap"` |
|
|
601
|
+
| Colour prior in attempts (`shotCells` and `signaturePoints` `prior`, `shrunkDiff`'s `k`) | 25 | 0 |
|
|
602
|
+
| Palette (`diffScale` `palette`) | `"rdbu"` | `"master"` |
|
|
603
|
+
| Signature curve (`shootingSignature`, `appendSignature` `curve`) | `"monotone-x"` | `"basis"` |
|
|
604
|
+
| Signature half-width (`shootingSignature` `halfWidth`, in y units) | the default, `max(share / maxShare * 20, 0.75) / 190` | `(p) => (1 + p.share * 199) / 200` (in `appendSignature`'s pixels on master's 200 px axis, `(p) => 1 + p.share * 199`) |
|
|
605
|
+
| Legend span (`appendLegend` `domain` and `ticks`) | the scale's ends (±0.15 for `"rdbu"`), ticks at the ends and 0 | `{ domain: [-0.3, 0.3], ticks: [-0.3, -0.15, 0, 0.15, 0.3] }` |
|
|
606
|
+
| `signaturePoints` options | the defaults, `{ step: 0.25, smooth: true, minAttempts: 5, prior: 25 }` | `{ step: 1, smooth: false, minAttempts: 1, prior: 0 }` |
|
|
607
|
+
| `statsBySide` `centreHalfWidth` (4th argument) | `0`: `x == 0` is centre | `false`: `x == 0` is dropped |
|
|
608
|
+
|
|
609
|
+
- Hexagons or squares (J38): every lattice option takes `{ radius }` (hexagons, as both apps), `{ shape: "square", side }`,
|
|
610
|
+
or `{ shape: "square", radius, equalArea: true }` (squares of that hexagon's area). Pass the same `shape` to
|
|
611
|
+
`shotCells` and to `appendLegend`'s `size` key. `binner` bins any x/y data, not only shots.
|
|
612
|
+
- Hover: `tip: true` on `shotCells`, `shotZones` (given `stats: statsByZone(shots)`, each zone's makes/attempts) and
|
|
613
|
+
`shootingSignature` ([tips](#tips-and-the-figures-value)). `shotCells` leaves out a cell centred outside the plot's
|
|
614
|
+
frame (`dropOutside`, default `true`; it was `clip`), so a tip never points at a cell that is not drawn, and Plot's
|
|
615
|
+
own `clip` passes through. The shot marks draw your `cells` and `areas` as the mark's data, so `fx`/`fy`,
|
|
616
|
+
`channels` and the tip read your fields; every other Plot geo option passes through. As on the image marks, a
|
|
617
|
+
`transform` or `initializer` that makes new rows (`Plot.group`, `Plot.hexbin`) throws `InputError`.
|
|
618
|
+
- Linking: each cell's path carries `data-sdv-id` `"x,y"` (its legacy centre) and each zone's its name, so
|
|
619
|
+
`linkSelection(store, { figure, select: "toggle" })` from `sdvplot/interact` toggles them by click, Enter or Space.
|
|
620
|
+
Hexagon ids and zone names are two id spaces, so give the cells and the zones a store each. A zone's label passes
|
|
621
|
+
the pointer through to its zone. A click also pins Plot's tip, so give `shotCells` `tip: { pointerEvents: "none" }`
|
|
622
|
+
(with your `maxRadius`): the pinned tip then never takes a click meant for a cell under it
|
|
623
|
+
([guide](https://plot.sportsdataverse.org/guides/shot-charts#linking)).
|
|
624
|
+
- The binners (`hexbin`, `squarebin`, `binner`, `hexagonPath`, `squarePath`, `cellPath`, `cellPoints`) import from
|
|
625
|
+
`sdvplot/shots` or from the sporty-free `sdvplot/bins`. The types the Plot and d3 marks take (`CellVsLeague`,
|
|
626
|
+
`SignaturePoint`, `DiffScale`, `BinShape`) are exported from `sdvplot/shots`, and `BinShape` from `sdvplot/bins` too,
|
|
627
|
+
not from `sdvplot/plot` or `sdvplot/d3`.
|
|
628
|
+
- `shootingSignature` does not clamp y: the ribbon's edges are `fgPct ± halfWidth`, so near 0% or 100% they pass a
|
|
629
|
+
[0, 1] domain (`main` clamps the ribbon's centre, which misstates FG%). Pass `y: { domain: [0, 1], clamp: true }` to
|
|
630
|
+
clamp. `appendSignature` uses your `y` scale as it is; a clamped scale clamps the centre.
|
|
631
|
+
- Marks paint in array order. `shotZones` returns the zone paths, then the labels, then the tip, and
|
|
632
|
+
`shootingSignature`'s tip is its last mark too, so spread these marks last, or add other marks before them: a mark
|
|
633
|
+
added after them paints over the tip. Zone fills after `...court.marks` dim the court lines under them, and drawing
|
|
634
|
+
the court last would hide the zones under its floor. For lines on top, put a second `surface` right after the zone
|
|
635
|
+
paths, its `colorUpdates` setting `plot_background`, `defensive_half_court`, `offensive_half_court`, `court_apron`,
|
|
636
|
+
`two_point_range`, `painted_area`, `center_circle_fill` and `free_throw_circle_fill` to `"#00000000"`:
|
|
637
|
+
`const [paths, ...rest] = shotZones(areas, o)`, then `marks: [...court.marks, paths, ...lines.marks, ...rest]`.
|
|
638
|
+
- Faces: `appendHeadshots(…, { clip: "circle", ring, placeholder })` draws circular headshots. The image is drawn 2.3
|
|
639
|
+
radii tall so the head fills the circle, so `drawnMarks` reports 1.15 × `height` for a face.
|
|
640
|
+
- `<Headshot fallback="initials" name="…">` (React) shows the player's initials when there is no headshot or it fails
|
|
641
|
+
to load, server-rendered pages included. The initials box reads a new CSS variable, `--sdv-line` (its background;
|
|
642
|
+
default `#e2e2e2`), and `--sdv-muted` for the text.
|
|
643
|
+
|
|
644
|
+
## **Export (Node; peer: @resvg/resvg-js)**
|
|
645
|
+
|
|
646
|
+
`sdvplot/export` turns an SVG figure into a PNG with `@resvg/resvg-js`, an optional peer
|
|
647
|
+
(`pnpm add -D @resvg/resvg-js`; without it `toPNG` throws `OptionalDependencyError`). `socialCard` frames the figure
|
|
648
|
+
for a social post the way sdvplot's and sdvplotR's `gt_social_crop` do: padded, then the short side extended to the
|
|
649
|
+
ratio, never cropped.
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { writeFile } from "node:fs/promises";
|
|
653
|
+
import * as Plot from "@observablehq/plot";
|
|
654
|
+
import { loadLeague } from "@sportsdataverse/sdvplot";
|
|
655
|
+
import { socialCard, toPNG } from "@sportsdataverse/sdvplot/export";
|
|
656
|
+
import { logos } from "@sportsdataverse/sdvplot/plot";
|
|
657
|
+
import { JSDOM } from "jsdom";
|
|
658
|
+
|
|
659
|
+
await loadLeague("nfl");
|
|
660
|
+
// 2024 AFC regular season, points for and against (nflverse games)
|
|
661
|
+
const afc = [
|
|
662
|
+
{ team: "KC", pf: 385, pa: 326 },
|
|
663
|
+
{ team: "LAC", pf: 402, pa: 301 },
|
|
664
|
+
{ team: "DEN", pf: 425, pa: 311 },
|
|
665
|
+
{ team: "LV", pf: 309, pa: 434 },
|
|
666
|
+
{ team: "BUF", pf: 525, pa: 368 },
|
|
667
|
+
{ team: "MIA", pf: 345, pa: 364 },
|
|
668
|
+
{ team: "NYJ", pf: 338, pa: 404 },
|
|
669
|
+
{ team: "NE", pf: 289, pa: 417 },
|
|
670
|
+
];
|
|
671
|
+
const svg = Plot.plot({
|
|
672
|
+
document: new JSDOM("").window.document, // Plot in Node
|
|
673
|
+
width: 640,
|
|
674
|
+
inset: 24,
|
|
675
|
+
grid: true,
|
|
676
|
+
x: { label: "Points for" },
|
|
677
|
+
y: { label: "Points against", reverse: true },
|
|
678
|
+
marks: [logos(afc, { league: "nfl", x: "pf", y: "pa", team: "team" })],
|
|
679
|
+
});
|
|
680
|
+
const png = await toPNG(socialCard(svg.outerHTML, { aspect: "16:9", padding: 60 }), { width: 1600 });
|
|
681
|
+
await writeFile("afc.png", png);
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
- `toPNG(svg, { width, scale, background, color, images, fonts })` takes an SVG string or an element with `outerHTML`, and
|
|
685
|
+
adds the `xmlns` declarations an HTML-serialized figure lacks. Remote `<image>`s (logos, headshots) are downloaded
|
|
686
|
+
once each, at most 8 at a time; a failed download throws `DownloadError`, and `images: "skip"` leaves them out with
|
|
687
|
+
one warning. `color` is what `currentColor` (Plot's axes and text) resolves to; by default the SVG's own
|
|
688
|
+
root `color`, else black or white, whichever contrasts more with `background`.
|
|
689
|
+
- Text is drawn in the system fonts. On a host without any (slim Docker images, minimal CI runners) resvg draws every
|
|
690
|
+
label as nothing, with no error: pass font files, `toPNG(svg, { fonts: { files: ["./SourceSans3-Regular.ttf"] } })`
|
|
691
|
+
(`defaultFamily` names the family to fall back to; `system: false` skips the system-font scan), or install
|
|
692
|
+
fontconfig and a font. On GitHub Actions: `sudo apt-get install -y fontconfig fonts-dejavu-core`.
|
|
693
|
+
- `socialCard(svg, { aspect, padding, background, gravity, color })`: `aspect` is `"1:1"` (the default), `"16:9"`,
|
|
694
|
+
`"4:5"`, `"9:16"`, `"1.91:1"`, `"4x5"` or a number, and `gravity` is one of the nine ImageMagick names. Python's and
|
|
695
|
+
R's final `width=` rescale is `toPNG(…, { width })`.
|
|
696
|
+
- `toPNG` draws SVG only. A Plot figure with a `title`, `subtitle`, `caption` or legend is an HTML `<figure>`: pass the
|
|
697
|
+
`<svg>` inside it (`figure.querySelector("svg")`), which leaves out the HTML parts.
|
|
698
|
+
- Tables go to PNG through `@sportsdataverse/sdvtables/export` (playwright).
|
|
699
|
+
|
|
700
|
+
## **Linked interactivity**
|
|
701
|
+
|
|
702
|
+
One selection store links any number of figures and tables. `createSelection()` holds the ids under the pointer
|
|
703
|
+
(`hover`), the picked ids (`selected`), a brush region as a row test (`predicate`) and one shared hover value
|
|
704
|
+
(`cursor`, such as a shot distance). A change notifies each subscriber once, and a change that changes nothing notifies
|
|
705
|
+
nobody, so linked views cannot ping-pong. A figure's marks carry their link id as `data-sdv-id`: the `id` option on
|
|
706
|
+
`logos`/`wordmarks`/`headshots`, `render: linkIds(rows, "team")` on any other Plot mark, or
|
|
707
|
+
`.attr("data-sdv-id", (d) => d.team)` in d3. A table carries the same ids through its spec's `rowKey`.
|
|
708
|
+
|
|
709
|
+
```js
|
|
710
|
+
import * as Plot from "@observablehq/plot";
|
|
711
|
+
import { createSelection } from "@sportsdataverse/sdvplot";
|
|
712
|
+
import { brushFilter, linkSelection } from "@sportsdataverse/sdvplot/interact";
|
|
713
|
+
import { linkIds } from "@sportsdataverse/sdvplot/plot";
|
|
714
|
+
import { createTable, defineTable } from "@sportsdataverse/sdvtables";
|
|
715
|
+
import { hydrate, renderHTML } from "@sportsdataverse/sdvtables/html";
|
|
716
|
+
|
|
717
|
+
// 2024 AFC: wins and net EPA per play (nflverse). NE has none, so it has no dot and a brush never holds it.
|
|
718
|
+
const rows = [
|
|
719
|
+
{ team: "KC", wins: 15, net_epa: 0.063 },
|
|
720
|
+
{ team: "LAC", wins: 11, net_epa: 0.101 },
|
|
721
|
+
{ team: "DEN", wins: 10, net_epa: 0.108 },
|
|
722
|
+
{ team: "LV", wins: 4, net_epa: -0.146 },
|
|
723
|
+
{ team: "BUF", wins: 13, net_epa: 0.19 },
|
|
724
|
+
{ team: "MIA", wins: 8, net_epa: -0.019 },
|
|
725
|
+
{ team: "NYJ", wins: 5, net_epa: -0.045 },
|
|
726
|
+
{ team: "NE", wins: 4, net_epa: null },
|
|
727
|
+
];
|
|
728
|
+
const fig = Plot.plot({ marks: [Plot.dot(rows, { x: "wins", y: "net_epa", r: 6, render: linkIds(rows, "team") })] });
|
|
729
|
+
const spec = defineTable()
|
|
730
|
+
.columns((c) => [c.text("team"), c.int("wins"), c.num("net_epa", { digits: 3 })])
|
|
731
|
+
.rowKey("team")
|
|
732
|
+
.build();
|
|
733
|
+
const table = createTable(spec, rows);
|
|
734
|
+
const host = document.createElement("div");
|
|
735
|
+
host.innerHTML = renderHTML(table); // or <SdvTable table={table} /> from @sportsdataverse/sdvtables/react
|
|
736
|
+
hydrate(host.querySelector(".sdvt"), table);
|
|
737
|
+
|
|
738
|
+
const store = createSelection();
|
|
739
|
+
linkSelection(store, { figure: fig }); // hover a dot: its row is underlined; a brush or a selection lights the dots
|
|
740
|
+
linkSelection(store, { table }); // hover or click a row: its dot lights; a brush filters the table
|
|
741
|
+
brushFilter(fig, store, { data: rows, x: "wins", y: "net_epa", id: "team" });
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
- **Highlighting** is a class toggle (`.sdv-focus` on the figure, `.sdv-hl` on the emphasised marks), never a redraw.
|
|
745
|
+
Each store change touches only the marks whose state changed, and marks Plot draws later (a `Plot.pointer` layer)
|
|
746
|
+
are lit as they appear. Axis logos (`axisLogos`) never dim and never hover.
|
|
747
|
+
- **Ids are strings and must match on both sides.** `toId` normalises them: `12` and `"12"` are one id, and null,
|
|
748
|
+
NaN and `""` are none. An image mark without `id` is stamped with the resolved ESPN team id ("12" for KC; the player
|
|
749
|
+
id on headshots), which never matches a table keyed by abbreviation: pass `id: "team"`. `linkSelection` warns once
|
|
750
|
+
per figure when none of the focused ids is drawn.
|
|
751
|
+
- **Image marks link through `id`.** `render: linkIds(...)` also works on them, with or without `href`; when both are
|
|
752
|
+
set, the outer `render` wins. With `href`, the stamp sits on the `<image>` inside the `<a>` (on any other mark, on
|
|
753
|
+
the `<a>`), and `select: "toggle"` throws `InputError`, because a checkbox cannot sit inside a link.
|
|
754
|
+
- **A Plot figure with a `tip`** hovers through Plot's own pointer:
|
|
755
|
+
`linkSelection(store, { figure: fig, hover: { id: (d) => d.team } })` writes the row Plot's tip picks. The `id`
|
|
756
|
+
function must return the same key as the marks' `id`; otherwise the tip writes one id and the marks carry another.
|
|
757
|
+
Pass `figure` as `Plot.plot` returned it (a `<figure>` when there is a caption or legend). On a figure with a `tip`,
|
|
758
|
+
the press that starts a brush also pins the tip showing at that moment, as a press on any Plot tip does.
|
|
759
|
+
- **Linked tables.** A table holds one hover id, so it lights the first id the store hovers. Every interactive
|
|
760
|
+
sdvtables table, linked or not, underlines the row under the pointer; drop it with
|
|
761
|
+
`tr.sdvt-hover>td.sdvt-cell{background-image:none}` (sdvtables' README has the details).
|
|
762
|
+
- **`brushFilter(fig, store, { data, x, y, id, empty, scales })`** brushes x and y, or one alone along that axis. A
|
|
763
|
+
brush holding no row dims every mark (`empty: "dim"`, the default) or, with `empty: "clear"`, is no filter and is
|
|
764
|
+
removed when the gesture ends. A d3-drawn chart has no `figure.scale`, so it passes its own d3 scales as `scales`.
|
|
765
|
+
The drawn rectangle follows the store: cleared from elsewhere (`store.clear()`), it goes without a second write. A
|
|
766
|
+
click on empty chart area clears only this brush's region. The handle's `move(region)` brushes in data coordinates.
|
|
767
|
+
- **`linkSelection(store, { figure, select: "toggle" })`** turns each stamped mark into a checkbox (`role`,
|
|
768
|
+
`tabindex="0"`, `aria-checked`): a click, Enter or Space toggles its id in `selected`. Each checkbox is named by the
|
|
769
|
+
mark's own name, from Plot's `ariaLabel` or `title` channel, else by its link id. A mark stamped with an empty id (a
|
|
770
|
+
missing key) stays a plain mark.
|
|
771
|
+
- **`linkCursor(fig, store, { field, shape })`** draws the store's `cursor` through this figure's own scale and writes
|
|
772
|
+
the value under the pointer back: a rule, or a band `width` data units wide, on an x or y scale; the band holding the
|
|
773
|
+
value on a band scale; or a ring around a centre (`axis: "ring"`, such as a shot distance around the hoop). A
|
|
774
|
+
d3-drawn chart passes its d3 scales as they are (`d3.scaleLinear()`, `d3.scaleBand()`), as `brushFilter`'s `scales` do.
|
|
775
|
+
Options: `snap`, `label` and `dot`. Moving inside one snapped bin writes nothing, and leaving the figure clears the
|
|
776
|
+
cursor it wrote, never one another figure or `store.set` wrote since. A cursor is not an id: it never dims marks or
|
|
777
|
+
filters a table. The store's `Cursor` is unrelated to
|
|
778
|
+
sdvtables' `TableCursor`, the keyboard grid's current row.
|
|
779
|
+
- **`nearestHover(svg, store, { points, radius, dimension })`** hovers the nearest mark of a d3-drawn (or any
|
|
780
|
+
non-Plot) figure within `radius` px, in the plane or along one axis, and its `label` option shows `tooltip`'s
|
|
781
|
+
in-SVG box beside it. Link that figure with `hover: false`, so it has one hover writer.
|
|
782
|
+
`tooltip(svg).show(x, y, lines, swatch)` draws the same box on its own.
|
|
783
|
+
- **Teardown.** `linkSelection` and `linkCursor` return a teardown function, since teardown is all they have
|
|
784
|
+
(`useEffect(() => linkSelection(store, o), deps)` is one line); `brushFilter`, `nearestHover` and `tooltip` return a
|
|
785
|
+
handle with `destroy()`, since they have more (`move`, `update`, `show`). `linkSelection` takes the store first because
|
|
786
|
+
it links a figure, a table or both; the other calls take the element first.
|
|
787
|
+
`linkSelection`'s teardown clears the hover its own link wrote, and `linkCursor`'s the cursor its own figure wrote,
|
|
788
|
+
only while the store still holds exactly that value: another writer's survives. `linkSelection`'s teardown then
|
|
789
|
+
restores the view it changed, so a figure or table unlinked and kept on the page shows no store state, even
|
|
790
|
+
mid-brush: the figure un-dims (once the last link on it goes), toggled marks get their own attributes back, and the
|
|
791
|
+
table drops the brush filter and the hover it showed. The store keeps the brush's region and any selection for the
|
|
792
|
+
views still linked. **To replace a linked figure, tear its link down before linking the new one**; otherwise the
|
|
793
|
+
new figure's first sync reads the old figure's hover and warns that none of the linked ids is drawn.
|
|
794
|
+
- **Styling.** Five CSS custom properties restyle the visuals with no JS, so a page's light and dark tokens reach
|
|
795
|
+
them: `--sdv-dim-opacity` (default `0.2`, the dimmed marks), `--sdv-cursor-color` (default `rgba(0,0,0,.2)`),
|
|
796
|
+
`--sdv-cursor-width` (default `10px`, the stroke of a rule or a ring; a band is filled), `--sdv-tip-bg` (default
|
|
797
|
+
`rgba(34,34,34,.85)`) and `--sdv-tip-fg` (default `#ddd`, the text and the swatch outline).
|
|
798
|
+
- **Server rendering.** Without a DOM every interact call is a no-op (`hasDom()`), so server-rendered markup is
|
|
799
|
+
unchanged; a bad option still throws `InputError` in Node. **React:** `useSelection(store)` (`sdvplot/react`)
|
|
800
|
+
returns the store's state and re-renders on each change.
|
|
801
|
+
|
|
802
|
+
The docs run these live: [Linked figure and table](https://plot.sportsdataverse.org/examples/linked), and a five-chart
|
|
803
|
+
[linked shot dashboard](https://plot.sportsdataverse.org/examples/shot-dashboard) on one store.
|
|
804
|
+
|
|
805
|
+
## **Data provenance**
|
|
806
|
+
|
|
807
|
+
Curation lives only in the Python `sdvplot` repo (spec J4); this package ships generated per-league `.ts` shards (J13, `src/data/**`, never hand-edited) and a best-marks slice of the CDN manifest (J14), regenerated with `SDVPLOT_PY_REPO=… pnpm build:index` (which also writes `src/data/CHECKSUMS`) and drift-gated in CI by `pnpm build:index --check` (digest verification). Regenerate with:
|
|
808
|
+
|
|
809
|
+
```sh
|
|
810
|
+
SDVPLOT_PY_REPO=/path/to/sdvplot pnpm build:index
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Parity is enforced by the Python oracle (`pnpm oracle:sdvplot`): the test suite replays every input × resolve/team colours/logo URLs/palette, plus headshot cases, against the Python package and requires 100% agreement.
|
|
814
|
+
|
|
815
|
+
## **Documentation**
|
|
816
|
+
|
|
817
|
+
The [**sdvplot-js** documentation website](https://plot.sportsdataverse.org) has the
|
|
818
|
+
[sdvplot gallery](https://plot.sportsdataverse.org/gallery/sdvplot/), the [notebooks](https://plot.sportsdataverse.org/notebooks/) and the
|
|
819
|
+
[sdvplot API reference](https://plot.sportsdataverse.org/api/sdvplot/), one page per subpath, plus:
|
|
820
|
+
|
|
821
|
+
**Guides:**
|
|
822
|
+
[Identity and resolution](https://plot.sportsdataverse.org/guides/identity) ·
|
|
823
|
+
[Team colours](https://plot.sportsdataverse.org/guides/colors) ·
|
|
824
|
+
[Logos, wordmarks, headshots](https://plot.sportsdataverse.org/guides/marks) ·
|
|
825
|
+
[Observable Plot](https://plot.sportsdataverse.org/guides/observable-plot) ·
|
|
826
|
+
[D3](https://plot.sportsdataverse.org/guides/d3) ·
|
|
827
|
+
[React](https://plot.sportsdataverse.org/guides/react) ·
|
|
828
|
+
[Plotly, Vega-Lite, ECharts and Chart.js](https://plot.sportsdataverse.org/guides/chart-libraries) ·
|
|
829
|
+
[Shot charts](https://plot.sportsdataverse.org/guides/shot-charts) ·
|
|
830
|
+
[Node and SSR](https://plot.sportsdataverse.org/guides/node-ssr) ·
|
|
831
|
+
[Export to PNG](https://plot.sportsdataverse.org/guides/export)
|
|
832
|
+
|
|
833
|
+
**Examples:**
|
|
834
|
+
[Basketball](https://plot.sportsdataverse.org/examples/basketball) ·
|
|
835
|
+
[Football](https://plot.sportsdataverse.org/examples/football) ·
|
|
836
|
+
[Hockey](https://plot.sportsdataverse.org/examples/hockey) ·
|
|
837
|
+
[Linked figures and tables](https://plot.sportsdataverse.org/examples/linked) ·
|
|
838
|
+
[A linked shot dashboard](https://plot.sportsdataverse.org/examples/shot-dashboard)
|
|
839
|
+
|
|
840
|
+
**Coming from elsewhere:**
|
|
841
|
+
[sdvplot (Python), sdvplotR and sportyR](https://plot.sportsdataverse.org/guides/migrating) ·
|
|
842
|
+
[Game on Paper (Chart.js + Astro/Svelte)](https://plot.sportsdataverse.org/guides/game-on-paper) ·
|
|
843
|
+
[blazing-the-nets (Next/React + d3)](https://plot.sportsdataverse.org/guides/blazing-the-nets)
|
|
844
|
+
|
|
845
|
+
The [repository README](https://github.com/sportsdataverse/sdvplot-js#readme) covers all three packages and development.
|
|
846
|
+
|
|
847
|
+
## **Logos, trademarks and data**
|
|
848
|
+
|
|
849
|
+
Team names, logos, wordmarks and player headshots are trademarks or copyrighted works of their respective leagues,
|
|
850
|
+
teams, schools and other rights holders. `@sportsdataverse/sdvplot` is not affiliated with, sponsored by or endorsed
|
|
851
|
+
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:
|
|
852
|
+
it carries an index of team names, ids and colors and the addresses of the archived marks, and the marks are
|
|
853
|
+
fetched at runtime from the [SportsDataverse logo archive](https://github.com/sportsdataverse/sdv-assets),
|
|
854
|
+
headshots from ESPN (or, for NFL gsis ids, the headshot URLs in nflverse's player table). Use of any mark in your own
|
|
855
|
+
work is governed by that owner's terms, and following them is your responsibility.
|
|
856
|
+
|
|
857
|
+
The NFL team colors and the gsis-id headshot map behind `loadGsis()` come from
|
|
858
|
+
[nflverse-data](https://github.com/nflverse/nflverse-data) by the nflverse project, licensed
|
|
859
|
+
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): headshot URLs, not images. The team index is curated in
|
|
860
|
+
[sdvplot](https://sdvplot.sportsdataverse.org/) (Python, MIT). The `bins` and `shots` subpaths port d3-hexbin,
|
|
861
|
+
d3-interpolate, d3-scale-chromatic (ColorBrewer's RdBu) and d3-array under their own licences.
|
|
862
|
+
|
|
863
|
+
The [MIT license](https://github.com/sportsdataverse/sdvplot-js/blob/main/LICENSE) covers the code; team data belongs to its respective owners and sources.
|
|
864
|
+
[NOTICE.md](https://github.com/sportsdataverse/sdvplot-js/blob/main/packages/sdvplot/NOTICE.md) records the sdvplot/sdvplotR, nflverse and d3 material and its licences.
|
|
865
|
+
|
|
866
|
+
## **The SportsDataverse**
|
|
867
|
+
|
|
868
|
+
`@sportsdataverse/sdvplot` is one of the three [sdvplot-js](https://github.com/sportsdataverse/sdvplot-js#readme) packages, with [`@sportsdataverse/sporty`](https://github.com/sportsdataverse/sdvplot-js/tree/main/packages/sporty#readme) and [`@sportsdataverse/sdvtables`](https://github.com/sportsdataverse/sdvplot-js/tree/main/packages/sdvtables#readme).
|
|
869
|
+
|
|
870
|
+
| Package | Sport / Scope |
|
|
871
|
+
| --- | --- |
|
|
872
|
+
| [**sdvplot**](https://sdvplot.sportsdataverse.org/) | The Python package sdvplot-js ports |
|
|
873
|
+
| [**sdvplotR**](https://sdvplotR.sportsdataverse.org/) | The R package sdvplot mirrors |
|
|
874
|
+
| [**sportyR**](https://github.com/sportsdataverse/sportyR) · [**sportypy**](https://sportypy.sportsdataverse.org/) | Playing-surface plots for R and Python, which `@sportsdataverse/sporty` ports |
|
|
875
|
+
| [**sportsdataverse.js**](https://js.sportsdataverse.org/) | SportsDataverse data for Node.js and TypeScript |
|
|
876
|
+
| [**sportsdataverse-py**](https://py.sportsdataverse.org/) | SportsDataverse data for Python: NFL, CFB, NBA, WNBA, MBB, WBB, MLB, NHL, PWHL, soccer and more |
|
|
877
|
+
| [**sportsdataverse-R**](https://r.sportsdataverse.org/) | The R packages: hoopR, wehoop, cfbfastR, fastRhockey, baseballr and more |
|
|
878
|
+
|
|
879
|
+
See the full ecosystem at [sportsdataverse.org](https://sportsdataverse.org/).
|
|
880
|
+
|
|
881
|
+
## **Follow the [SportsDataverse](https://x.com/SportsDataverse) on X and star this repo**
|
|
882
|
+
|
|
883
|
+
[](https://x.com/SportsDataverse)
|
|
884
|
+
[](https://github.com/sportsdataverse/sdvplot-js/stargazers)
|
|
885
|
+
|
|
886
|
+
## **Our Authors**
|
|
887
|
+
|
|
888
|
+
- [Saiem Gilani](https://x.com/saiemgilani)
|
|
889
|
+
<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>
|
|
890
|
+
<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>
|
|
891
|
+
|
|
892
|
+
## **Citations**
|
|
893
|
+
|
|
894
|
+
To cite [**`@sportsdataverse/sdvplot`**](https://plot.sportsdataverse.org) in publications, cite sdvplot-js:
|
|
895
|
+
|
|
896
|
+
BibTeX Citation
|
|
897
|
+
|
|
898
|
+
```bibtex
|
|
899
|
+
@misc{gilani_2026_sdvplot_js,
|
|
900
|
+
author = {Gilani, Saiem},
|
|
901
|
+
title = {sdvplot-js: Team identity, colors, logos, surfaces and tables for JavaScript and TypeScript plots},
|
|
902
|
+
url = {https://plot.sportsdataverse.org},
|
|
903
|
+
year = {2026}
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
## **License**
|
|
908
|
+
|
|
909
|
+
MIT. See [NOTICE.md](https://github.com/sportsdataverse/sdvplot-js/blob/main/packages/sdvplot/NOTICE.md) for the sdvplot/sdvplotR, nflverse and d3 material.
|