Linked figures and tables
One selection store links any number of figures and tables (@sportsdataverse/sdvplot/interact). The store holds the
ids under the pointer, the picked ids, a brush region and one shared hover value. Every linked figure follows it with a
class toggle, never a redraw. A linked table underlines a hovered row the same way, but a selection or a brush
re-renders its body: a selection marks rows, and a brush filters them. A figure's marks carry their ids as
data-sdv-id (linkIds, the id option on the image marks, or one .attr in d3); a table carries the same ids
through its rowKey. The figures on this page run live: brush, hover, click. In Node the interact calls are no-ops, so
the server-rendered markup is unchanged. The sdvplot
README lists
the rules: matching ids, tips, tearing a link down, the CSS custom properties.
A brushable scatter and a hydrated table
The 2024 AFC (see Sample data). Brush the chart: the table keeps the
brushed teams, selected. Hover a dot: its row is underlined. Hover or click a row: its dot lights. The ids match
because the dots' linkIds channel and the table's rowKey both read team.
| KC | West | 15 | 2 | 0.063 |
| BUF | East | 13 | 4 | 0.190 |
| LAC | West | 11 | 6 | 0.101 |
| DEN | West | 10 | 7 | 0.108 |
import * as Plot from "@observablehq/plot";
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createSelection } from "@sportsdataverse/sdvplot";
import { brushFilter, linkSelection } from "@sportsdataverse/sdvplot/interact";
import { linkIds } from "@sportsdataverse/sdvplot/plot";
import { createTable, defineTable } from "@sportsdataverse/sdvtables";
import { hydrate, renderHTML } from "@sportsdataverse/sdvtables/html";
// One store links both views. Brush the chart: the table keeps the brushed teams, selected. Hover a dot: its row is
// underlined; hover or click a row: its dot lights. The ids match because the dots' id channel and the table's rowKey
// both read `team`. New England has no net EPA, so no dot: its row lights nothing, and a brush never holds it.
const team = linkIds(STANDINGS, "team");
const svg = Plot.plot({
grid: true,
x: { label: "Wins", domain: [0, 17] },
y: { label: "Net EPA per play" },
marks: [
Plot.dot(STANDINGS, { x: "wins", y: "net_epa", r: 6, fill: "currentColor", render: team }),
Plot.text(STANDINGS, { x: "wins", y: "net_epa", text: "team", dy: -12, render: team }),
],
});
const spec = defineTable<Standing>()
.columns((c) => [
c.text("team"),
c.text("division"),
c.int("wins"),
c.int("losses"),
c.num("net_epa", { digits: 3, label: "Net EPA" }),
])
.title("AFC, 2024")
.rowKey("team")
.build();
const table = createTable(spec, STANDINGS, { sort: { col: "wins", dir: "desc" } });
const store = createSelection<Standing>();
linkSelection(store, { figure: svg });
linkSelection(store, { table });
brushFilter(svg, store, { data: STANDINGS, x: "wins", y: "net_epa", id: "team" }).move({
x: [9.5, 16],
y: [0, 0.2],
});
const host = document.createElement("div");
host.innerHTML = renderHTML(table, { fonts: false }); // rendered from the brushed engine, then hydrated: one state
hydrate(host.querySelector(".sdvt") as Element, table);
const root = document.createElement("div");
root.append(svg, host);
root;
Ids on any Plot mark
linkIds(rows, "team") stamps each mark with its row's id, so one id lights a dot and its label together.
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { highlight } from "@sportsdataverse/sdvplot/interact";
import { linkIds } from "@sportsdataverse/sdvplot/plot";
// Every dot and label carries its team as data-sdv-id, so one id lights both layers and the rest dim: a class
// toggle on the marks that changed, never a redraw. New England has no net EPA, so it has no dot to light.
const team = linkIds(STANDINGS, "team");
const svg = Plot.plot({
grid: true,
x: { label: "Wins" },
y: { label: "Net EPA per play" },
marks: [
Plot.dot(STANDINGS, { x: "wins", y: "net_epa", r: 6, fill: "currentColor", render: team }),
Plot.text(STANDINGS, { x: "wins", y: "net_epa", text: "team", dy: -12, render: team }),
],
});
highlight(svg, new Set(["KC", "BUF"])); // the two division winners, until the pointer moves
svg.addEventListener("pointerover", (e) => {
const id = (e.target as Element).closest("[data-sdv-id]")?.getAttribute("data-sdv-id");
highlight(svg, id ? new Set([id]) : null);
});
svg;
A brush writes the store
brushFilter writes the ids it encloses as the selection, and the region as a row test that a linked table applies to
its own rows.
Brushed: KC, LAC, DEN, BUF
import * as Plot from "@observablehq/plot";
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createSelection, focusIds } from "@sportsdataverse/sdvplot";
import { brushFilter, highlight } from "@sportsdataverse/sdvplot/interact";
import { linkIds } from "@sportsdataverse/sdvplot/plot";
// Drag a rectangle: the teams inside become the store's selection (lit, the rest dim) and the region becomes a row
// test that a linked table can apply to its own rows. The overlay sits behind the dots, so a dot still takes the
// pointer. New England has no net EPA, so no brush ever holds it.
const team = linkIds(STANDINGS, "team");
const svg = Plot.plot({
grid: true,
x: { label: "Wins", domain: [0, 17] },
y: { label: "Net EPA per play" },
marks: [
Plot.dot(STANDINGS, { x: "wins", y: "net_epa", r: 6, fill: "currentColor", render: team }),
Plot.text(STANDINGS, { x: "wins", y: "net_epa", text: "team", dy: -12, render: team }),
],
});
const store = createSelection<Standing>();
const readout = document.createElement("p");
store.subscribe((state) => {
highlight(svg, focusIds(state));
const kept = STANDINGS.filter((r) => state.predicate?.(r)).map((r) => r.team);
readout.textContent = state.predicate
? `Brushed: ${kept.join(", ") || "none"}`
: "Drag on the chart to brush";
});
brushFilter(svg, store, { data: STANDINGS, x: "wins", y: "net_epa", id: "team" }).move({
x: [9.5, 16],
y: [0, 0.2],
});
const root = document.createElement("div");
root.append(svg, readout);
root;
Marks as checkboxes
With select: "toggle", each stamped mark is a checkbox: click it, or Tab to it and press Enter or Space, to toggle its
id in the selection. The example clicks the win at Indiana, so it starts checked and lit; the chart below is live, so
click a game, or Tab to one and press Enter or Space.
import * as Plot from "@observablehq/plot";
import { createSelection } from "@sportsdataverse/sdvplot";
import { linkSelection } from "@sportsdataverse/sdvplot/interact";
import { linkIds } from "@sportsdataverse/sdvplot/plot";
const games = [
{ game_id: "0022500130", game_date: "2025-10-29", matchup: "BKN vs. ATL", wl: "L" },
{ game_id: "0022500149", game_date: "2025-11-02", matchup: "BKN vs. PHI", wl: "L" },
{ game_id: "0022500156", game_date: "2025-11-03", matchup: "BKN vs. MIN", wl: "L" },
{ game_id: "0022500173", game_date: "2025-11-05", matchup: "BKN @ IND", wl: "W" },
{ game_id: "0022500031", game_date: "2025-11-07", matchup: "BKN vs. DET", wl: "L" },
];
const svg = Plot.plot({
height: 60,
x: { type: "band" },
marks: [
Plot.cell(games, { x: "game_date", fill: "wl", ariaLabel: "matchup", render: linkIds(games, "game_id") }),
],
});
const store = createSelection<(typeof games)[number]>();
linkSelection(store, { figure: svg, select: "toggle" }); // each cell: role="checkbox", tabindex="0", aria-checked
svg.querySelector('[data-sdv-id="0022500173"]')?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
svg; // the win at Indiana checked and lit, the rest dimmed: click a game, or Tab to it and press Enter or Space
One hover value across charts
linkCursor follows the store's cursor, one number such as a shot distance, through each chart's own scale: a band
on the share bars, the 3 ft band that holds it on the FG% bars, a band up the side chart, and a ring around the hoop.
The linked shot dashboard builds blazing-the-nets' five-chart dashboard the same way.
import * as Plot from "@observablehq/plot";
import { BKN_SHOTS_2026 } from "@sportsdataverse/examples/data";
import { createSelection } from "@sportsdataverse/sdvplot";
import { linkCursor } from "@sportsdataverse/sdvplot/interact";
import { surface } from "@sportsdataverse/sdvplot/plot";
import { fgPctByDistance, statsBySide } from "@sportsdataverse/sdvplot/shots";
import { toSurfaceFrame } from "@sportsdataverse/sporty";
// Brooklyn's 2025-26 shots. The store holds ONE number, the shot distance under the pointer; each chart draws it
// through its own scale. Hover the share bars, the FG% bars or the side chart: every cursor follows, and the ring
// around the hoop shows how far out that is. Leaving a chart clears them all.
const D = "shot_distance";
const byFoot = fgPctByDistance(BKN_SHOTS_2026, 1, 35);
const by3 = fgPctByDistance(BKN_SHOTS_2026, 3, 35);
const sides = statsBySide(BKN_SHOTS_2026, 1, 35); // x == 0 counts as centre
const within = byFoot.reduce((n, b) => n + b.attempts, 0);
const snap = (feet: number): number => Math.floor(feet) + 0.5; // one value per 1 ft bin
const store = createSelection();
// Share of attempts by foot, on a linear x: a band one foot wide, with master's readout (it flips near 35 ft)
const share = Plot.plot({
width: 640,
height: 220,
x: { label: "Shot distance (ft)" },
y: { label: "Share of attempts (%)", percent: true },
marks: [Plot.rectY(byFoot, { x1: "distance", x2: (b) => b.distance + 1, y: "share", inset: 0.5 })],
});
linkCursor(share, store, {
field: D,
shape: { axis: "x", scale: share.scale("x")!, width: 1 },
snap,
label: (feet) => {
const b = byFoot[Math.floor(feet)];
return b === undefined
? []
: [`${(100 * b.share).toFixed(1)}%`, `${b.attempts} of ${within}`, `@ ${b.distance} ft`];
},
});
// FG% in 3 ft bins, on a band x (no invert): the cursor lights the band holding the distance
const fg = Plot.plot({
width: 640,
height: 220,
x: { label: "Shot distance (ft, 3 ft bins)" },
y: { label: "FG%", percent: true, domain: [0, 100] }, // percent scales by 100 before the domain applies
marks: [Plot.barY(by3, { x: "distance", y: "fgPct" })],
});
linkCursor(fg, store, { field: D, shape: { axis: "x", scale: fg.scale("x")! } });
// Attempts left and right of the hoop by foot, distance on y: the same value as a horizontal band
const side = Plot.plot({
width: 320,
height: 420,
x: { label: "← left attempts right →", tickFormat: Math.abs },
y: { label: "Shot distance (ft)" },
marks: [
Plot.rect(sides, { x1: (b) => -b.left.attempts, x2: 0, y1: "distance", y2: (b) => b.distance + 1 }),
Plot.rect(sides, { x1: 0, x2: (b) => b.right.attempts, y1: "distance", y2: (b) => b.distance + 1 }),
Plot.ruleX([0]),
],
});
linkCursor(side, store, { field: D, shape: { axis: "y", scale: side.scale("y")!, width: 1 }, snap });
// The shots on the defensive half court, and a ring around the hoop (-41.75, 0) at the cursor's distance
// (spread into plain rows: toSurfaceFrame takes any record, and an interface type is not one)
const shots = toSurfaceFrame(
BKN_SHOTS_2026.map((s) => ({ ...s })),
{ from: "nba-legacy" },
);
const court = surface("nba", { displayRange: "defense" });
const chart = Plot.plot({
...court.scales,
width: 640,
marks: [
...court.marks,
Plot.dot(shots, { x: "surface_x", y: "surface_y", r: 1.5, fill: "shot_result", fillOpacity: 0.6 }),
],
});
linkCursor(chart, store, {
field: D,
shape: { axis: "ring", x: chart.scale("x")!, y: chart.scale("y")!, center: [-41.75, 0] },
});
store.set({ cursor: { field: D, value: 26.5 } }); // just behind the arc above the break: Brooklyn's busiest foot
const root = document.createElement("div");
root.append(share, fg, side, chart);
root;
A d3 chart: the nearest mark and a tooltip
A d3 figure links through the same store. nearestHover hovers the nearest shot within 18 px (or the nearest bar along
x), and its label shows tooltip's in-SVG box at the mark.
import { BKN_SHOTS_2026 } from "@sportsdataverse/examples/data";
import { createSelection, toId } from "@sportsdataverse/sdvplot";
import { linkSelection, nearestHover } from "@sportsdataverse/sdvplot/interact";
import { fgPctByDistance } from "@sportsdataverse/sdvplot/shots";
import * as d3 from "d3";
// Two d3 figures of Brooklyn's first 2000 shots of 2025-26 share one store, keyed by distance in feet. They hover the
// NEAREST mark rather than the element under the pointer, as blazing-the-nets' d3 charts do: on the shot chart, a
// shot within 18 px (a Delaunay search); on the bars, the foot under the pointer's x alone, with a 5 px dead margin.
// Either way every shot from that distance lights, the rest dim, and a tooltip shows at the mark.
const bins = fgPctByDistance(BKN_SHOTS_2026, 1, 35); // feet 0 to 35
const label = (id: string): { lines: string[] } | null => {
const b = bins[Number(id)];
if (b === undefined) return null; // the heaves from 38 and 39 ft have no bar: they hover, with no tooltip
return {
lines: [
`${b.distance} ft`,
`${b.makes} of ${b.attempts} made`,
`${(100 * b.share).toFixed(1)}% of shots`,
],
};
};
const store = createSelection();
const k = 0.8; // px per tenth of a foot, hoop at x_legacy = y_legacy = 0
const at = (s: (typeof BKN_SHOTS_2026)[number]): [number, number] => [
k * (s.x_legacy + 250),
k * (420 - s.y_legacy),
];
const court = d3
.create("svg")
.attr("viewBox", [0, 0, 500 * k, 470 * k])
.attr("width", 500 * k);
court
.append("circle")
.attr("cx", 250 * k)
.attr("cy", 420 * k)
.attr("r", 7.5 * k)
.attr("fill", "none")
.attr("stroke", "currentColor");
court
.selectAll("circle.shot")
.data(BKN_SHOTS_2026)
.join("circle")
.attr("class", "shot")
.attr("data-sdv-id", (s) => toId(s.shot_distance)) // the link id: the shot's distance, as the bars stamp theirs
.attr("cx", (s) => at(s)[0])
.attr("cy", (s) => at(s)[1])
.attr("r", 2.5)
.attr("fill", (s) => (s.shot_result === "Made" ? "currentColor" : "none"))
.attr("stroke", "currentColor")
.attr("stroke-width", 0.6);
const courtNode = court.node() as SVGSVGElement;
linkSelection(store, { figure: courtNode, hover: false }); // the store lights the marks; nearestHover writes hover
nearestHover(courtNode, store, {
points: BKN_SHOTS_2026.map((s) => ({ x: at(s)[0], y: at(s)[1], id: toId(s.shot_distance) })),
radius: 18,
label,
});
const [w, h] = [500 * k, 170];
const x = d3.scaleLinear([0, 36], [0, w]);
const y = d3.scaleLinear([0, d3.max(bins, (b) => b.share) ?? 1], [h, 12]);
const bars = d3.create("svg").attr("viewBox", [0, 0, w, h]).attr("width", w);
bars
.selectAll("rect")
.data(bins)
.join("rect")
.attr("data-sdv-id", (b) => toId(b.distance))
.attr("x", (b) => x(b.distance) + 0.5)
.attr("width", x(1) - x(0) - 1)
.attr("y", (b) => y(b.share))
.attr("height", (b) => h - y(b.share))
.attr("fill", "currentColor");
const barsNode = bars.node() as SVGSVGElement;
linkSelection(store, { figure: barsNode, hover: false });
nearestHover(barsNode, store, {
points: bins.map((b) => ({ x: x(b.distance + 0.5), y: y(b.share), id: toId(b.distance) })),
dimension: "x", // the bar under the pointer's x, however far below its top the pointer is
padding: 5,
label,
});
const root = document.createElement("div");
root.append(courtNode, barsNode);
root;
React
useSelection(store) re-renders a component on every change of the store; what to redraw from it is the component's
choice.
| team | W-L | QB |
|---|---|---|
| KC | 15-2 | Patrick Mahomes |
| LAC | 11-6 | Justin Herbert |
| BUF | 13-4 | Josh Allen |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createSelection, toId } from "@sportsdataverse/sdvplot";
import { useSelection } from "@sportsdataverse/sdvplot/react";
import { type ReactElement, useMemo } from "react";
// One store for the page: every component that links reads and writes it (a figure's linkSelection would too).
const store = createSelection<Standing>();
const elevenPlus = (r: Standing): boolean => r.wins >= 11;
store.set({ predicate: elevenPlus, selected: ["KC"] }); // start brushed to 11+ wins, KC picked
function Teams(): ReactElement {
const { hover, selected } = useSelection(store);
return (
<p>
{STANDINGS.map((r) => {
const id = toId(r.team);
const picked = selected.has(id);
return (
<button
key={id}
type="button"
aria-pressed={picked}
// the hover outline, else none set: an inline "none" would hide the keyboard focus ring
style={{ fontWeight: picked ? 700 : 400, outline: hover.has(id) ? "2px solid" : undefined }}
onPointerEnter={() => store.set({ hover: [id] })}
onPointerLeave={() => store.set({ hover: [] })}
onClick={() => store.set({ selected: picked ? [] : [id] })}
>
{r.team}
</button>
);
})}{" "}
<button type="button" onClick={() => store.set({ predicate: elevenPlus })}>
11+ wins
</button>
<button type="button" onClick={() => store.clear()}>
clear
</button>
</p>
);
}
function Standings(): ReactElement {
const { hover, selected, predicate } = useSelection(store);
// Filter-then-redraw is the component's choice: the rows are recomputed only when the brush region changes.
const rows = useMemo(() => (predicate === null ? STANDINGS : STANDINGS.filter(predicate)), [predicate]);
return (
<table>
<caption>{`${rows.length} of ${STANDINGS.length} AFC teams, 2024`}</caption>
<thead>
<tr>
<th>team</th>
<th>W-L</th>
<th>QB</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const id = toId(r.team);
return (
<tr
key={id}
style={{
fontWeight: selected.has(id) ? 700 : 400,
background: hover.has(id) ? "var(--sdv-line, #e2e2e2)" : "none",
}}
>
<td>{r.team}</td>
<td>{`${r.wins}-${r.losses}`}</td>
<td>{r.qb}</td>
</tr>
);
})}
</tbody>
</table>
);
}
(
<>
<Teams />
<Standings />
</>
);