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.

Tanya Shapiro
  • Home
  • About
  • Talks
  • Blog
  • Projects

NBA Shot Charts

Observable
interactive
Analysis of NBA player shooting stats for 2022-23 regular season. Interactive shot charts created with Observable Plot.

Background

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
OJS Error

Error: Unable to load file: data/shots_player.csv

Error: Unable to load file: data/player_images.csv
OJS Error

Error: Unable to load file: data/player_images.csv

RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

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>`;
  };
}
markings = ƒ(…)

Player Shot Chart

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;
  }
});
RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

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"})
  })
  )
plot_input = "Scatter"
RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

Unable to load file

RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

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>`
}
RuntimeError: Unable to load file: data/player_images.csv
OJS Runtime Error

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()
  ]
})
RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

Unable to load file

RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

Unable to load file

RuntimeError: Unable to load file: data/shots_player.csv
OJS Runtime Error

Unable to load file

 
    Created with Quarto
    Copyright © 2023 Tanya Shapiro. All rights reserved.
Cookie Preferences