diff --git a/scripts/players/shots.ipynb b/scripts/players/shots.ipynb
new file mode 100644
index 0000000..48be04e
--- /dev/null
+++ b/scripts/players/shots.ipynb
@@ -0,0 +1,360 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "e24e324a",
+ "metadata": {},
+ "source": [
+ "# Data processing - shots"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "23dc2285",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-05-03T01:58:39.762810Z",
+ "iopub.status.busy": "2026-05-03T01:58:39.762641Z",
+ "iopub.status.idle": "2026-05-03T01:58:40.666805Z",
+ "shell.execute_reply": "2026-05-03T01:58:40.666511Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "\n",
+ "DATA_PATH = \"../../data/\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "400eaa7f",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-05-03T01:58:40.668530Z",
+ "iopub.status.busy": "2026-05-03T01:58:40.668382Z",
+ "iopub.status.idle": "2026-05-03T01:58:40.670319Z",
+ "shell.execute_reply": "2026-05-03T01:58:40.670107Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def get_season(date):\n",
+ " year = date.year\n",
+ " return year if date.month >= 9 else year - 1"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a6aa3f0b",
+ "metadata": {},
+ "source": [
+ "## Load shot detail data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "a00d3dce",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-05-03T01:58:40.671515Z",
+ "iopub.status.busy": "2026-05-03T01:58:40.671421Z",
+ "iopub.status.idle": "2026-05-03T01:58:40.675033Z",
+ "shell.execute_reply": "2026-05-03T01:58:40.674764Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "58"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import glob\n",
+ "\n",
+ "shot_files = sorted(glob.glob(DATA_PATH + \"/nba_play_by_play_shot_data/shotdetail*.csv\"))\n",
+ "\n",
+ "len(shot_files)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bcb172e9",
+ "metadata": {},
+ "source": [
+ "## Process data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "b04c8b23",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-05-03T01:58:40.676114Z",
+ "iopub.status.busy": "2026-05-03T01:58:40.676029Z",
+ "iopub.status.idle": "2026-05-03T01:58:52.607418Z",
+ "shell.execute_reply": "2026-05-03T01:58:52.607055Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " season | \n",
+ " personId | \n",
+ " shotValue | \n",
+ " locX | \n",
+ " locY | \n",
+ " shotMade | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 1996 | \n",
+ " 120 | \n",
+ " 2 | \n",
+ " 0 | \n",
+ " 0 | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 1996 | \n",
+ " 120 | \n",
+ " 2 | \n",
+ " 153 | \n",
+ " 71 | \n",
+ " 0 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 1996 | \n",
+ " 363 | \n",
+ " 2 | \n",
+ " -103 | \n",
+ " 26 | \n",
+ " 1 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 1996 | \n",
+ " 120 | \n",
+ " 2 | \n",
+ " 0 | \n",
+ " 0 | \n",
+ " 0 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 1996 | \n",
+ " 87 | \n",
+ " 2 | \n",
+ " 0 | \n",
+ " 0 | \n",
+ " 0 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " season personId shotValue locX locY shotMade\n",
+ "0 1996 120 2 0 0 1\n",
+ "1 1996 120 2 153 71 0\n",
+ "2 1996 363 2 -103 26 1\n",
+ "3 1996 120 2 0 0 0\n",
+ "4 1996 87 2 0 0 0"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# GRID_TYPE,GAME_ID,GAME_EVENT_ID,PLAYER_ID,PLAYER_NAME,TEAM_ID,TEAM_NAME,PERIOD,MINUTES_REMAINING,SECONDS_REMAINING,\n",
+ "# EVENT_TYPE,ACTION_TYPE,SHOT_TYPE,SHOT_ZONE_BASIC,SHOT_ZONE_AREA,SHOT_ZONE_RANGE,SHOT_DISTANCE,LOC_X,LOC_Y,SHOT_ATTEMPTED_FLAG,SHOT_MADE_FLAG,GAME_DATE,HTM,VTM\n",
+ "\n",
+ "# hoop is at (LOC_X, LOC_Y) = (0, 0)\n",
+ "\n",
+ "cols_to_keep = [\n",
+ " \"GAME_DATE\", \"PLAYER_ID\",\n",
+ " \"SHOT_TYPE\", \"LOC_X\", \"LOC_Y\",\n",
+ " \"SHOT_ATTEMPTED_FLAG\", \"SHOT_MADE_FLAG\"\n",
+ "]\n",
+ "\n",
+ "frames = []\n",
+ "\n",
+ "for file_path in shot_files:\n",
+ " # only regular season data; \"_po_\" files are playoffs\n",
+ " if \"_po_\" in file_path.lower():\n",
+ " continue\n",
+ "\n",
+ " df = pd.read_csv(file_path, usecols=lambda c: c in cols_to_keep)\n",
+ "\n",
+ " # 1 = actual shot attempt\n",
+ " if \"SHOT_ATTEMPTED_FLAG\" in df.columns:\n",
+ " df = df[df[\"SHOT_ATTEMPTED_FLAG\"] == 1]\n",
+ "\n",
+ " df[\"GAME_DATE\"] = pd.to_datetime(df[\"GAME_DATE\"], format=\"%Y%m%d\", errors=\"coerce\")\n",
+ " df = df[df[\"GAME_DATE\"].notna()]\n",
+ " df[\"season\"] = df[\"GAME_DATE\"].apply(get_season)\n",
+ " df = df.drop(columns=[\"GAME_DATE\", \"SHOT_ATTEMPTED_FLAG\"])\n",
+ "\n",
+ " # cleanup coordinates\n",
+ " df[\"LOC_X\"] = pd.to_numeric(df[\"LOC_X\"], errors=\"coerce\")\n",
+ " df[\"LOC_Y\"] = pd.to_numeric(df[\"LOC_Y\"], errors=\"coerce\")\n",
+ " df = df.dropna(subset=[\"LOC_X\", \"LOC_Y\"])\n",
+ "\n",
+ " frames.append(df)\n",
+ "\n",
+ "shot_events = pd.concat(frames, ignore_index=True)\n",
+ "\n",
+ "shot_events = shot_events.rename(columns={\n",
+ " \"PLAYER_ID\": \"personId\",\n",
+ " \"SHOT_TYPE\": \"shotType\",\n",
+ " \"LOC_X\": \"locX\",\n",
+ " \"LOC_Y\": \"locY\",\n",
+ " \"SHOT_MADE_FLAG\": \"shotMade\",\n",
+ "})\n",
+ "\n",
+ "# SHOT_TYPE: 3PT Field Goal or 2PT Field Goal\n",
+ "shot_events[\"shotValue\"] = shot_events[\"shotType\"].str.extract(r\"(\\d)PT\")[0].astype(\"Int64\")\n",
+ "\n",
+ "shot_events = shot_events[[\n",
+ " \"season\", \"personId\", \"shotValue\", \"locX\", \"locY\", \"shotMade\"\n",
+ "]]\n",
+ "\n",
+ "shot_events.head()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "060371e7",
+ "metadata": {},
+ "source": [
+ "### Check range for locX, locY"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "796fb878",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-05-03T01:58:52.609116Z",
+ "iopub.status.busy": "2026-05-03T01:58:52.608994Z",
+ "iopub.status.idle": "2026-05-03T01:58:52.621124Z",
+ "shell.execute_reply": "2026-05-03T01:58:52.620863Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "locX: (np.int64(-250), np.int64(250))\n",
+ "locY: (np.int64(-52), np.int64(884))\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"locX:\", (shot_events[\"locX\"].min(), shot_events[\"locX\"].max()))\n",
+ "print(\"locY:\", (shot_events[\"locY\"].min(), shot_events[\"locY\"].max()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0be67554",
+ "metadata": {},
+ "source": [
+ "## Save data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "66adbed6",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-05-03T01:58:52.622454Z",
+ "iopub.status.busy": "2026-05-03T01:58:52.622375Z",
+ "iopub.status.idle": "2026-05-03T01:58:57.338711Z",
+ "shell.execute_reply": "2026-05-03T01:58:57.338403Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/opt/homebrew/Caskroom/miniconda/base/envs/.venv/lib/python3.11/site-packages/numpy/_core/fromnumeric.py:57: FutureWarning: 'DataFrame.swapaxes' is deprecated and will be removed in a future version. Please use 'DataFrame.transpose' instead.\n",
+ " return bound(*args, **kwds)\n"
+ ]
+ }
+ ],
+ "source": [
+ "import numpy as np\n",
+ "from pathlib import Path\n",
+ "\n",
+ "output_dir = Path(DATA_PATH) / \"processed\"\n",
+ "output_dir.mkdir(parents=True, exist_ok=True)\n",
+ "\n",
+ "parts = np.array_split(shot_events, 3)\n",
+ "\n",
+ "for i, part in enumerate(parts, 1):\n",
+ " part.to_csv(output_dir / f\"shot_events_part{i}.csv\", index=False)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python (other-env)",
+ "language": "python",
+ "name": "other-env"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/src/data/nba-court.svg b/src/data/nba-court.svg
new file mode 100644
index 0000000..378d423
--- /dev/null
+++ b/src/data/nba-court.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/index.html b/src/index.html
index 303789f..51e6fe4 100644
--- a/src/index.html
+++ b/src/index.html
@@ -98,7 +98,7 @@
-
+
diff --git a/src/js/BubbleMap.js b/src/js/BubbleMap.js
index d668b1d..05d92d2 100644
--- a/src/js/BubbleMap.js
+++ b/src/js/BubbleMap.js
@@ -273,6 +273,7 @@ export class BubbleMap {
this.statsItem = item;
this.statsUpdate(this.stats, this.seasonsLoader, this.metadataLoader, this.currentYear, item);
+ this.statsArea.style.background = bubble.style.background;
this.stats.classList.add("active");
});
diff --git a/src/js/ShotChart.js b/src/js/ShotChart.js
new file mode 100644
index 0000000..758cb7a
--- /dev/null
+++ b/src/js/ShotChart.js
@@ -0,0 +1,58 @@
+import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
+
+const toSvgX = locX => 218.6 + locX * 0.8684;
+const toSvgY = locY => 89.8 + locY * 0.761;
+
+export function drawShotChart(svgEl, shots) {
+ const svg = d3.select(svgEl);
+ svg.selectAll("*").remove();
+
+ svg.attr("viewBox", "0 0 601 444")
+ .attr("preserveAspectRatio", "xMidYMid meet");
+
+ svg.append("image")
+ .attr("href", "data/nba-court.svg")
+ .attr("width", 601)
+ .attr("height", 444);
+
+ const filtered = shots.filter(d => +d.locY <= 470);
+ const made = filtered.filter(d => +d.shotMade === 1);
+ const missed = filtered.filter(d => +d.shotMade === 0);
+
+ svg.selectAll("circle")
+ .data(made)
+ .join("circle")
+ .attr("cx", d => toSvgX(+d.locX))
+ .attr("cy", d => toSvgY(+d.locY))
+ .attr("r", 4)
+ .attr("fill", "none")
+ .attr("stroke", "#31e77d")
+ .attr("stroke-width", 1.5)
+ .attr("opacity", 0.7);
+
+ const cross = d3.symbol().type(d3.symbolCross).size(40);
+
+ svg.selectAll("path")
+ .data(missed)
+ .join("path")
+ .attr("d", cross)
+ .attr("transform", d => `translate(${toSvgX(+d.locX)},${toSvgY(+d.locY)}) rotate(45)`)
+ .attr("fill", "#ee5847")
+ .attr("opacity", 0.7);
+}
+
+export async function loadShots(personId, season) {
+ const parts = [
+ "data/shot_events_part1.csv",
+ "data/shot_events_part2.csv",
+ "data/shot_events_part3.csv",
+ ];
+ const frames = await Promise.all(parts.map((p, i) =>
+ d3.csv(p)
+ ));
+ const all = frames.flat();
+ console.log(`${personId} ${season}`);
+ const result = all.filter(d => +d.personId === +personId && +d.season === +season);
+ console.log(`shots found: ${result.length}`);
+ return result;
+}
diff --git a/src/js/stats.js b/src/js/stats.js
index 7fbf786..acb6a55 100644
--- a/src/js/stats.js
+++ b/src/js/stats.js
@@ -1,5 +1,6 @@
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
import * as Data from "./data.js";
+import { drawShotChart, loadShots } from "./ShotChart.js";
// TODO set all attributes
@@ -82,19 +83,6 @@ export function updatePlayerStats(container, seasonsLoader, metadataLoader, curr
let playerData = seasonData.get(playerId);
content.innerText = attributesDisplay.map((display, index) => display + ": " + playerData[index]).join("\n");
- // D3.js example
- const dataMap = new Map([
- ["1", [35, 117]], ["2", [43, 114]], ["3", [40, 118]], ["4", [22, 115]]
- ]);
- const data = Array.from(dataMap.values()).map(d => d[0]);
-
- d3.select("#player-chart")
- .selectAll("rect")
- .data(data)
- .join("rect")
- .attr("x", (_, i) => i * 45 + 10)
- .attr("y", d => 150 - d)
- .attr("width", 40)
- .attr("height", d => d)
- .attr("fill", "steelblue");
+ const chartEl = document.getElementById("player-chart");
+ loadShots(playerId, currentYear).then(shots => drawShotChart(chartEl, shots));
}
diff --git a/src/stats.css b/src/stats.css
index a71d3b1..80b1c8f 100644
--- a/src/stats.css
+++ b/src/stats.css
@@ -6,11 +6,8 @@
background: #91bdff;
- width: 85%;
- height: 80%;
-
- border: 2px solid black;
- border-radius: 20px;
+ width: 100%;
+ height: 100%;
}
.stats-close {