From 4ffbd57986dcfb8d9dabd7b9bbcb374f92bc60a5 Mon Sep 17 00:00:00 2001 From: jamsamjam Date: Fri, 1 May 2026 02:07:50 +0200 Subject: [PATCH 1/5] feat: add shots processing --- scripts/players/shots.ipynb | 363 ++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 scripts/players/shots.ipynb diff --git a/scripts/players/shots.ipynb b/scripts/players/shots.ipynb new file mode 100644 index 0000000..a0f3fac --- /dev/null +++ b/scripts/players/shots.ipynb @@ -0,0 +1,363 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e24e324a", + "metadata": {}, + "source": [ + "# Data processing - shots" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "23dc2285", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "DATA_PATH = \"../../data/\"" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "400eaa7f", + "metadata": {}, + "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": 19, + "id": "a00d3dce", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "58" + ] + }, + "execution_count": 19, + "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": 20, + "id": "b04c8b23", + "metadata": {}, + "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", + " \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", + "
seasongameIdgameDatepersonIdteamIdminutesRemainingsecondsRemainingshotValuelocXlocYshotMade
01996296000051996-11-0112016106127371182001
11996296000051996-11-01120161061273710322153710
21996296000051996-11-0136316106127379382-103261
31996296000051996-11-0112016106127378572000
41996296000051996-11-018716106127378562000
\n", + "
" + ], + "text/plain": [ + " season gameId gameDate personId teamId minutesRemaining \\\n", + "0 1996 29600005 1996-11-01 120 1610612737 11 \n", + "1 1996 29600005 1996-11-01 120 1610612737 10 \n", + "2 1996 29600005 1996-11-01 363 1610612737 9 \n", + "3 1996 29600005 1996-11-01 120 1610612737 8 \n", + "4 1996 29600005 1996-11-01 87 1610612737 8 \n", + "\n", + " secondsRemaining shotValue locX locY shotMade \n", + "0 8 2 0 0 1 \n", + "1 32 2 153 71 0 \n", + "2 38 2 -103 26 1 \n", + "3 57 2 0 0 0 \n", + "4 56 2 0 0 0 " + ] + }, + "execution_count": 20, + "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_ID\", \"GAME_DATE\", \"PLAYER_ID\", \"PLAYER_NAME\", \"TEAM_ID\", \"TEAM_NAME\",\n", + " \"PERIOD\", \"MINUTES_REMAINING\", \"SECONDS_REMAINING\",\n", + " \"SHOT_TYPE\", \"SHOT_DISTANCE\", \"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", + "\n", + " # cleanup corrdinates\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", + " \"GAME_ID\": \"gameId\",\n", + " \"GAME_DATE\": \"gameDate\",\n", + " \"PLAYER_ID\": \"personId\",\n", + " \"TEAM_ID\": \"teamId\",\n", + " \"MINUTES_REMAINING\": \"minutesRemaining\",\n", + " \"SECONDS_REMAINING\": \"secondsRemaining\",\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\", \"gameId\", \"gameDate\",\n", + " \"personId\", \"teamId\",\n", + " \"minutesRemaining\", \"secondsRemaining\",\n", + " \"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": 21, + "id": "796fb878", + "metadata": {}, + "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": 22, + "id": "66adbed6", + "metadata": {}, + "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 +} From d5c05a200300f6df1697438871a6e6b32ccc855e Mon Sep 17 00:00:00 2001 From: jamsamjam Date: Fri, 1 May 2026 02:25:04 +0200 Subject: [PATCH 2/5] feat(web): make stats panel full-width + team color --- src/js/BubbleMap.js | 1 + src/stats.css | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) 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/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 { From 9fde2ebcdfd5c0185efda6f75ed31924876383b8 Mon Sep 17 00:00:00 2001 From: jamsamjam Date: Fri, 1 May 2026 03:40:01 +0200 Subject: [PATCH 3/5] feat: show empty court --- src/data/nba-court.svg | 3 +++ src/index.html | 2 +- src/js/ShotChart.js | 24 ++++++++++++++++++++++++ src/js/stats.js | 18 +++--------------- 4 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 src/data/nba-court.svg create mode 100644 src/js/ShotChart.js 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/ShotChart.js b/src/js/ShotChart.js new file mode 100644 index 0000000..609e3d5 --- /dev/null +++ b/src/js/ShotChart.js @@ -0,0 +1,24 @@ +import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm"; + +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); +} + +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 => d3.csv(p))); + return frames.flat().filter(d => +d.personId === +personId && +d.season === +season); +} 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)); } From e67e71fe5a692175d4901c4b19f7ccfc5581be70 Mon Sep 17 00:00:00 2001 From: jamsamjam Date: Sun, 3 May 2026 04:00:14 +0200 Subject: [PATCH 4/5] fix: update shot_events processing --- scripts/players/shots.ipynb | 137 ++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 70 deletions(-) diff --git a/scripts/players/shots.ipynb b/scripts/players/shots.ipynb index a0f3fac..48be04e 100644 --- a/scripts/players/shots.ipynb +++ b/scripts/players/shots.ipynb @@ -10,9 +10,16 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 1, "id": "23dc2285", - "metadata": {}, + "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", @@ -22,9 +29,16 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 2, "id": "400eaa7f", - "metadata": {}, + "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", @@ -42,9 +56,16 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 3, "id": "a00d3dce", - "metadata": {}, + "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": { @@ -52,7 +73,7 @@ "58" ] }, - "execution_count": 19, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -75,9 +96,16 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 4, "id": "b04c8b23", - "metadata": {}, + "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": { @@ -101,12 +129,7 @@ " \n", " \n", " season\n", - " gameId\n", - " gameDate\n", " personId\n", - " teamId\n", - " minutesRemaining\n", - " secondsRemaining\n", " shotValue\n", " locX\n", " locY\n", @@ -117,12 +140,7 @@ " \n", " 0\n", " 1996\n", - " 29600005\n", - " 1996-11-01\n", " 120\n", - " 1610612737\n", - " 11\n", - " 8\n", " 2\n", " 0\n", " 0\n", @@ -131,12 +149,7 @@ " \n", " 1\n", " 1996\n", - " 29600005\n", - " 1996-11-01\n", " 120\n", - " 1610612737\n", - " 10\n", - " 32\n", " 2\n", " 153\n", " 71\n", @@ -145,12 +158,7 @@ " \n", " 2\n", " 1996\n", - " 29600005\n", - " 1996-11-01\n", " 363\n", - " 1610612737\n", - " 9\n", - " 38\n", " 2\n", " -103\n", " 26\n", @@ -159,12 +167,7 @@ " \n", " 3\n", " 1996\n", - " 29600005\n", - " 1996-11-01\n", " 120\n", - " 1610612737\n", - " 8\n", - " 57\n", " 2\n", " 0\n", " 0\n", @@ -173,12 +176,7 @@ " \n", " 4\n", " 1996\n", - " 29600005\n", - " 1996-11-01\n", " 87\n", - " 1610612737\n", - " 8\n", - " 56\n", " 2\n", " 0\n", " 0\n", @@ -189,22 +187,15 @@ "" ], "text/plain": [ - " season gameId gameDate personId teamId minutesRemaining \\\n", - "0 1996 29600005 1996-11-01 120 1610612737 11 \n", - "1 1996 29600005 1996-11-01 120 1610612737 10 \n", - "2 1996 29600005 1996-11-01 363 1610612737 9 \n", - "3 1996 29600005 1996-11-01 120 1610612737 8 \n", - "4 1996 29600005 1996-11-01 87 1610612737 8 \n", - "\n", - " secondsRemaining shotValue locX locY shotMade \n", - "0 8 2 0 0 1 \n", - "1 32 2 153 71 0 \n", - "2 38 2 -103 26 1 \n", - "3 57 2 0 0 0 \n", - "4 56 2 0 0 0 " + " 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": 20, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -216,9 +207,8 @@ "# hoop is at (LOC_X, LOC_Y) = (0, 0)\n", "\n", "cols_to_keep = [\n", - " \"GAME_ID\", \"GAME_DATE\", \"PLAYER_ID\", \"PLAYER_NAME\", \"TEAM_ID\", \"TEAM_NAME\",\n", - " \"PERIOD\", \"MINUTES_REMAINING\", \"SECONDS_REMAINING\",\n", - " \"SHOT_TYPE\", \"SHOT_DISTANCE\", \"LOC_X\", \"LOC_Y\",\n", + " \"GAME_DATE\", \"PLAYER_ID\",\n", + " \"SHOT_TYPE\", \"LOC_X\", \"LOC_Y\",\n", " \"SHOT_ATTEMPTED_FLAG\", \"SHOT_MADE_FLAG\"\n", "]\n", "\n", @@ -238,8 +228,9 @@ " 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 corrdinates\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", @@ -249,12 +240,7 @@ "shot_events = pd.concat(frames, ignore_index=True)\n", "\n", "shot_events = shot_events.rename(columns={\n", - " \"GAME_ID\": \"gameId\",\n", - " \"GAME_DATE\": \"gameDate\",\n", " \"PLAYER_ID\": \"personId\",\n", - " \"TEAM_ID\": \"teamId\",\n", - " \"MINUTES_REMAINING\": \"minutesRemaining\",\n", - " \"SECONDS_REMAINING\": \"secondsRemaining\",\n", " \"SHOT_TYPE\": \"shotType\",\n", " \"LOC_X\": \"locX\",\n", " \"LOC_Y\": \"locY\",\n", @@ -265,10 +251,7 @@ "shot_events[\"shotValue\"] = shot_events[\"shotType\"].str.extract(r\"(\\d)PT\")[0].astype(\"Int64\")\n", "\n", "shot_events = shot_events[[\n", - " \"season\", \"gameId\", \"gameDate\",\n", - " \"personId\", \"teamId\",\n", - " \"minutesRemaining\", \"secondsRemaining\",\n", - " \"shotValue\", \"locX\", \"locY\", \"shotMade\"\n", + " \"season\", \"personId\", \"shotValue\", \"locX\", \"locY\", \"shotMade\"\n", "]]\n", "\n", "shot_events.head()" @@ -284,9 +267,16 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 5, "id": "796fb878", - "metadata": {}, + "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", @@ -312,9 +302,16 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 6, "id": "66adbed6", - "metadata": {}, + "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", From 59589975b908392d60c99653026e056fa66cbad9 Mon Sep 17 00:00:00 2001 From: jamsamjam Date: Sun, 3 May 2026 04:20:34 +0200 Subject: [PATCH 5/5] feat: shot chart displays shot events --- src/js/ShotChart.js | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/js/ShotChart.js b/src/js/ShotChart.js index 609e3d5..758cb7a 100644 --- a/src/js/ShotChart.js +++ b/src/js/ShotChart.js @@ -1,5 +1,8 @@ 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(); @@ -11,6 +14,31 @@ export function drawShotChart(svgEl, shots) { .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) { @@ -19,6 +47,12 @@ export async function loadShots(personId, season) { "data/shot_events_part2.csv", "data/shot_events_part3.csv", ]; - const frames = await Promise.all(parts.map(p => d3.csv(p))); - return frames.flat().filter(d => +d.personId === +personId && +d.season === +season); + 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; }