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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
seasonpersonIdshotValuelocXlocYshotMade
019961202001
119961202153710
219963632-103261
319961202000
41996872000
\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 {