Error: Unable to load file: data/shots_player.csv
Error: Unable to load file: data/player_images.csv
Unable to load file
We use cookies
We use cookies and other tracking technologies to improve your browsing experience on our website, to show you personalized content and targeted ads, to analyze our website traffic, and to understand where our visitors are coming from.
I’m always in awe of the visual content I see in the sports analytics world. This week, I wanted explore NBA stat data and give something new a shot. Wrangled data from nbastatR
to get the latest 2022-23 season stats for players and their shot locations. Initially, I tried out this graphic with ggplot2
and an easy to use library, BasketballAnalyzer
, that has a built in function to generate the court lines.
After I posted my ggplot experiments on Twitter, a different conversation sparked - how could we do this in Observable? Of course Mike Bostock swooped in to the rescue. He had already assembled an impressive notebook visualizing LeBron James’ shots on the half court. I used his original notebook as a baseline to iterate on the graphics I created in R. Thankfully Mike did all the leg work with the court lines - I was dreading revisiting my notes from high school geometry!
Below is the result of my latest version. You can also find the notebook with the code here, pretty cool what you can do with Observable Plot!
shots_player = FileAttachment("data/shots_player.csv").csv({typed: true})
player_images = FileAttachment("data/player_images.csv").csv({typed: true})
player_list = [...new Set(shots_player.map(i => i.namePlayer))].sort()
Error: Unable to load file: data/shots_player.csv
Error: Unable to load file: data/player_images.csv
Unable to load file
function markings({
fill = "#00000000",
stroke = "currentColor",
strokeWidth = 2,
strokeOpacity = 1
} = {}) {
// Ref. https://observablehq.com/@nor/nba-2018-19-shooting-effeciency
const angle = Math.atan(90 / 220);
const arc = d3.arc();
const lines = [
[-250, 420, 250, 420], // half
[-250, 450, -250, -50], // left
[250, 450, 250, -50], // right
[250, -50, -250, -50], // bottom
[-220, -50, -220, 90], // corner 3
[220, -50, 220, 90], // corner 3
[-80, -50, -80, 140], // paint
[80, -50, 80, 140], // paint
[-60, -50, -60, 140], // paint
[60, -50, 60, 140], // paint
[-80, 140, 80, 140], // free throw line
[-30, -10, 30, -10], // backboard
[0, -10, 0, -7.5], // rim
[-40, -10, -40, 0], // ra
[40, -10, 40, 0] // ra
];
const circles = [
[0, 0, 7.5], // rim
[0, 140, 60], // key
[0, 420, 20], // center court inner
[0, 420, 60] // center court outer
];
const arcs = [
[0, 0, 40, -Math.PI * 0.5, Math.PI * 0.5], // ra
[0, 0, 237.5, -Math.PI * 0.5 - angle, Math.PI * 0.5 + angle] // 3pt
];
return (index, {x, y}) => {
return htl.svg`<g fill=none stroke=${stroke} stroke-width=${strokeWidth} stroke-opacity=${strokeOpacity}>
${lines.map(([x1, y1, x2, y2]) => htl.svg`<line x1=${x(x1)} x2=${x(x2)} y1=${y(y1)} y2=${y(y2)}>`)}
${circles.map(([cx, cy, r]) => htl.svg`<ellipse cx=${x(cx)} cy=${y(cy)} rx=${Math.abs(x(r) - x(0))} ry=${Math.abs(y(r) - y(0))}>`)}
${arcs.map(([cx, cy, r, a1, a2]) => htl.svg`<path d="M${x(cx + r * Math.cos(a1 - Math.PI / 2))},${y(cy + r * Math.sin(a1 - Math.PI / 2))}A${Math.abs(x(r) - x(0))} ${Math.abs(y(r) - y(0))} 0 0 ${Math.sign(x(r) - x(0)) * Math.sign(y(r) - y(0)) > 0 ? 0 : 1} ${x(cx + r * Math.cos(a2 - Math.PI / 2))},${y(cy + r * Math.sin(a2 - Math.PI / 2))}">`)}
</g>`;
};
}
Settings
filteredShots = shots_player.filter(i => {
if (filters.player_input.includes(i.namePlayer) && i.distanceShot<=filters.distance_input) {
if (filters.event_input === "Missed Shot" || filters.event_input === "Made Shot") {
return i.typeEvent === filters.event_input;
} else {
return true;
}
} else {
return false;
}
});
Unable to load file
viewof plot_input = Inputs.select(['Scatter','Tile','Density'], {label:"Plot Type", value:'scatter'})
viewof filters = (
Inputs.form({
player_input:Inputs.select(player_list, {label: "Player", value: player_list}),
color_input: Inputs.select(new Map([["Event Type", "typeEvent"], ["FG Type", "typeShot"], ["Zone", "nameZone"], ["Zone Range", "zoneRange"], ["Distance Shot (feet)", "distanceShot"]]), {value: "typeEvent", label: "Color", disabled:plot_input === "Tile" ? true : false}),
distance_input:Inputs.range([0, 50], {step: 1, label:"Max Distance", value:50}),
tile_bin_input:Inputs.range([2, 50], {step: 1, label:"Tile Bin", value:10}),
event_input: Inputs.radio(new Map([["All", "All"], ["Missed", "Missed Shot"], ["Made", "Made Shot"]]) ,{label: "Shots", value:"All"})
})
)
Unable to load file
Unable to load file
create_card = { const playerTitle = document.querySelector('.player-card')
const filteredImage = player_images.filter(i => filters.player_input.includes(i.namePlayer));
const missedShots = filteredShots.filter((shot) => shot.typeEvent === "Missed Shot").length;
const madeShots = filteredShots.filter((shot) => shot.typeEvent === "Made Shot").length;
const allShots = filteredShots.length;
const team = filteredShots[1].nameTeam
const accuracy = (madeShots/(missedShots+madeShots))*100
const accuracy_label = accuracy.toFixed(1)
playerTitle.innerHTML = `
<div class="player-info">
<div class="player-image-container">
<img class="player-image" src=${filteredImage[0].image}>
</div>
<div class="team-title">
<div class="player-title">${filters.player_input}</div>
<div class="player-team">${team}</div>
</div>`
}
Unable to load file
mark =
// if mark is Scatter
plot_input==='Scatter' ?
Plot.dot(filteredShots,
{x:"locationX",
y:"locationY",
fill:`${filters.color_input}`,
title: (d) => `Shot ID #${d.idEvent} \n Event: ${d.typeEvent} \n Distance: ${d.distanceShot} ft}`,
stroke:"white",
strokeWidth:1,
r:8}) :
// if mark is Tile
plot_input==='Tile' ?
Plot.rect(filteredShots,
Plot.bin({fill: "count"}, {x: "locationX", y: "locationY", inset: 0, interval: filters.tile_bin_input})):
plot_input == "Density" ?
Plot.density(filteredShots,
{x:"locationX", y:"locationY", fill:`${filters.color_input}`}) : null;
colors = (plot_input === "Tile") ? {
type: "linear",
range: ["steelblue", "orange"],
interpolate: "hcl",
legend:true,
label: "Shot Frequency",
style:{fontSize: "18px", paddingBottom:'40px', paddingTop:'10px', paddingLeft:'5px'}
} :
// if color input is distance shot
(filters.color_input === "distanceShot") ? {
type: "linear",
range: ["steelblue", "orange"],
interpolate: "hcl",
legend:true,
label: "Distance Shot (ft)",
style:{fontSize: "18px", paddingBottom:'40px', paddingTop:'10px', paddingLeft:'5px'}
} :
filters.color_input==="typeEvent" ?
{ legend: true,
range: ["#EB4242", "#CCCCCC"],
domain : ["Made Shot", "Missed Shot"],
style:{fontSize: "18px", paddingBottom:'40px', paddingTop:'10px', paddingLeft:'5px'}} :
filters.color_input==="typeShot" ?
{type: "categorical",
legend: true,
range: ["#F7CB15", "#2892D7"],
style:{fontSize: "18px", paddingBottom:'40px', paddingTop:'10px', paddingLeft:'5px'}
} :
filters.color_input=="zoneRange" ?
{legend: true,
type:"categorical",
domain: ["Less Than 8 ft.", "8-16 ft.", "16-24 ft.", "24+ ft."],
style:{fontSize: "18px", paddingBottom:'40px', paddingTop:'10px', paddingLeft:'5px'}} :
{legend:true,
style:{fontSize: "18px", paddingBottom:'40px', paddingTop:'10px', paddingLeft:'5px'}};
Plot.plot({
backgroundColor: '#00000000',
height: 620,
axis: null,
color: colors,
x: {domain: [-250, 250]},
y: {domain: [-50, 450]},
marks: [mark
,
markings()
]
})
Unable to load file
Unable to load file
Unable to load file