Using NHL API Shot Data
Fetch data for a specific NHL game, map player IDs to names using roster data, and plot all shot events on an interactive rink.
We will use the NHLDataManager class, which simplifies working with the NHL API by handling:
- Data Fetching: pulling live play-by-play data
- Coordinate Normalization: automatically flipping coordinates so teams always shoot towards the same end per period
- Roster Lookup: automatically parsing player names from game data
Complete Example
import {
Rink,
NHLDataManager,
colorByTeam,
scaleRadiusByProperty,
} from "d3-hockey";
// 1. Initialize the Rink
const rink = new Rink("#nhl-game-demo");
async function renderGame() {
try {
// 2. Initialize Manager with a specific Game ID
// Game 2022020195: EDM at WSH
const manager = await NHLDataManager.fromGameId("2022020195", {
flipCoordinates: true, // Standardize direction of play
flipOddPeriods: false, // Keep periods consistent
});
// 3. Get Shot Events
// Transform the data to include team abbreviations and names
const shotEvents = manager
.getAllEvents({ shotsOnly: true })
.map((event) => ({
...event,
// Helper to add team abbreviation since the API gives us IDs
teamAbbrev:
event.team === String(manager.homeTeam.id)
? manager.homeTeam.abbrev
: manager.awayTeam.abbrev,
// Helper to lookup player name
playerName:
manager.getPlayerName(event.playerId as number) || "Unknown Player",
}))
// Sorting to render goals last so they appear on top of other shots
.sort((a, b) => {
if (a.type === "goal") return 1;
if (b.type === "goal") return -1;
return 0;
});
// 4. Update Info Text
document.getElementById("game-info")!.innerHTML = `
<strong>${manager.awayTeam.name}</strong> vs
<strong>${manager.homeTeam.name}</strong><br>
${shotEvents.length} Total Shots
`;
// 5. Render Rink with Data
rink.render().addEvents(shotEvents, {
id: "game-shots",
// Dynamic Symbol based on Event Type
symbol: (d) => {
if (d.type === "goal") return "star";
if (d.type === "blocked-shot" || d.type === "missed-shot")
return "cross";
return "circle"; // shots on goal
},
// Size based on Event Type
radius: (d) => (d.type === "goal" ? 8 : 5),
// Color by Team (using secondary team colors)
color: colorByTeam("teamAbbrev", { colorType: "secondary" }),
// Detailed Tooltip
tooltip: (d) => `
<div style="font-family: sans-serif; line-height: 1.5; min-width: 150px;">
<div style="border-bottom: 1px solid #444; padding-bottom: 4px; margin-bottom: 4px;">
<strong>${d.playerName}</strong> <span style="color:#aaa">(${d.teamAbbrev})</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="text-transform: capitalize;">${d.type?.replace(/-/g, " ")}</span>
<strong>P${d.period} - ${d.time}</strong>
</div>
<div style="color: #bbb; font-size: 0.9em; margin-top: 2px;">
Loc: (${d.coordinates.x.toFixed(0)}, ${d.coordinates.y.toFixed(0)})
</div>
</div>
`,
// Add a stroke to make overlapping points clearer
stroke: "#000000",
strokeWidth: 1,
opacity: 0.65,
});
} catch (error) {
console.error("Failed to load game data:", error);
document.getElementById("game-info")!.innerText =
"Error loading game data.";
}
}
renderGame();Step-by-Step Breakdown
- Fetching Data with
NHLDataManager
The NHLDataManager.fromGameId() method handles fetching the play-by-play data and parses it into a usable format.
const manager = await NHLDataManager.fromGameId("2022020195");- Data Transformation and Sorting
We map over the events to inject derived properties (like player name and team abbreviations). We also sort the array to ensure specific events (like goals) are rendered last so they appear on top of overlapping events.
.sort((a, b) => {
if (a.type === "goal") return 1; // Move goals to the end (top)
return 0;
});- Customizing the Visualization
We use the configuration object to style the chart. Note the use of colorType: "secondary" in colorByTeam, this is useful when both teams have similar primary colors (e.g., Washington and Edmonton both use Navy Blue, so we switch to Red vs Orange).
color: colorByTeam("teamAbbrev", { colorType: "secondary" });