├── .gitignore ├── favicon.ico ├── postcss.config.js ├── src ├── assets │ ├── main_wireframe.png │ └── modal_wireframe.png ├── styles │ ├── reset.scss │ ├── graph.scss │ ├── animations.scss │ ├── modals.scss │ └── index.scss ├── scripts │ ├── api_util.js │ ├── api_parsing.js │ └── graph.js └── index.js ├── webpack.prod.js ├── webpack.dev.js ├── package.json ├── webpack.common.js ├── README.md └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MannanK/NBA-Stat-Race/HEAD/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; -------------------------------------------------------------------------------- /src/assets/main_wireframe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MannanK/NBA-Stat-Race/HEAD/src/assets/main_wireframe.png -------------------------------------------------------------------------------- /src/assets/modal_wireframe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MannanK/NBA-Stat-Race/HEAD/src/assets/modal_wireframe.png -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const common = require("./webpack.common.js"); 3 | 4 | module.exports = merge(common, { 5 | mode: "production", 6 | devtool: "source-map" 7 | }); -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const common = require("./webpack.common.js"); 3 | 4 | module.exports = merge(common, { 5 | mode: "development", 6 | devtool: "inline-source-map", 7 | devServer: { 8 | contentBase: "./", 9 | watchContentBase: true, 10 | open: "google-chrome" 11 | } 12 | }); -------------------------------------------------------------------------------- /src/styles/reset.scss: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } -------------------------------------------------------------------------------- /src/scripts/api_util.js: -------------------------------------------------------------------------------- 1 | import { parseSearchPlayerStats } from './api_parsing'; 2 | 3 | const apiUrl = "https://www.balldontlie.io/api/v1/"; 4 | 5 | function SEARCH_PLAYER_URL(playerName) { 6 | return ( 7 | apiUrl + 8 | "players?search=" + playerName + 9 | "&per_page=10" 10 | ); 11 | } 12 | 13 | // postseason false, per_page=82 14 | // https://www.balldontlie.io/api/v1/stats?player_ids[]=237&seasons[]=2018&postseason=false&per_page=82 15 | function SEARCH_PLAYER_STATS_URL(season, playerId) { 16 | return ( 17 | apiUrl + 18 | "stats?player_ids[]=" + playerId + 19 | "&seasons[]=" + season + 20 | "&postseason=false&per_page=82" 21 | ); 22 | } 23 | 24 | // sort by player names? 25 | export function searchPlayers(playerName) { 26 | return fetch(SEARCH_PLAYER_URL(playerName)) 27 | .then(resp => resp.json()) 28 | .then(data => { 29 | return data.data; 30 | }) 31 | .catch(error => console.log(error)); 32 | } 33 | 34 | export function searchPlayerStats(season, stat, playerId) { 35 | return fetch(SEARCH_PLAYER_STATS_URL(season, playerId)) 36 | .then(resp => resp.json()) 37 | .then(data => { 38 | if (data.data.length !== 0) { 39 | return parseSearchPlayerStats(data, stat); 40 | } else { 41 | return null; 42 | } 43 | }) 44 | .catch(error => console.log(error)); 45 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nba-stat-race", 3 | "version": "1.0.0", 4 | "description": "An NBA stats visualizer to plot the route a given stat took over the course of a given season for the selected players, leading up to the \"winner\" in that stat by season's end.", 5 | "main": "index.js", 6 | "browserslist": [ 7 | "last 1 version", 8 | "> 1%", 9 | "maintained node versions", 10 | "not dead" 11 | ], 12 | "scripts": { 13 | "start": "webpack-dev-server --config webpack.dev.js", 14 | "webpack:watch": "webpack --watch --config webpack.dev.js", 15 | "webpack:build": "webpack --config webpack.prod.js --optimize-minimize" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/MannanK/NBA-Stat-Race.git" 20 | }, 21 | "author": "mkasliw1", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/MannanK/NBA-Stat-Race/issues" 25 | }, 26 | "homepage": "https://github.com/MannanK/NBA-Stat-Race#readme", 27 | "dependencies": { 28 | "@babel/core": "^7.7.7", 29 | "@babel/preset-env": "^7.7.7", 30 | "autoprefixer": "^9.7.3", 31 | "babel-loader": "^8.0.6", 32 | "css-loader": "^3.4.1", 33 | "fibers": "^4.0.2", 34 | "lodash": "^4.17.15", 35 | "mini-css-extract-plugin": "^0.9.0", 36 | "node-sass": "^4.13.0", 37 | "postcss-loader": "^3.0.0", 38 | "sass": "^1.24.2", 39 | "sass-loader": "^8.0.0", 40 | "style-loader": "^1.1.2", 41 | "webpack": "^4.41.5", 42 | "webpack-cli": "^3.3.10", 43 | "webpack-dev-server": "^3.10.1", 44 | "webpack-merge": "^4.2.2" 45 | }, 46 | "devDependencies": { 47 | "@babel/plugin-proposal-optional-chaining": "^7.7.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/graph.scss: -------------------------------------------------------------------------------- 1 | #graph-container { 2 | /* background-color: rgb(7, 7, 7); */ 3 | background: transparent linear-gradient(90deg, rgba(28,28,28,1) 5%, rgba(201, 190, 38, 0.422), rgba(28,28,28,1) 95%); 4 | animation: color-change 6s ease-out; 5 | -webkit-animation: color-change 6s ease-out; 6 | margin: 0 auto; 7 | margin-bottom: 50px; 8 | height: 75vh; 9 | border-radius: 20px; 10 | position: relative; 11 | width: 100%; 12 | color: white; 13 | font-family: 'Signika', sans-serif; 14 | 15 | @media only screen and (max-device-width: 500px) { 16 | margin-bottom: 30px; 17 | } 18 | } 19 | 20 | .graph-glow { 21 | -webkit-animation: graph-glow 1200ms infinite !important; 22 | -moz-animation: graph-glow 1200ms infinite !important; 23 | -o-animation: graph-glow 1200ms infinite !important; 24 | animation: graph-glow 1200ms infinite !important; 25 | } 26 | 27 | // GRAPH ELEMENTS 28 | 29 | .line { 30 | stroke-width: .40%; 31 | fill: none; 32 | } 33 | 34 | .x-axis, .y-axis, .y-axis * { 35 | font: 14px sans-serif; 36 | 37 | @media (max-width: 500px) { 38 | font-size: 12px; 39 | } 40 | } 41 | 42 | .hover-info-container { 43 | position: absolute; 44 | background-color: rgba(90, 87, 87, 0.8); 45 | border-radius: 8px; 46 | padding: 10px; 47 | text-align: center; 48 | flex-direction: column; 49 | font-weight: bold; 50 | text-shadow: 0px 0px 6px black; 51 | 52 | font-size: 20px; 53 | 54 | @media only screen and (max-device-width: 500px) { 55 | font-size: 11px; 56 | } 57 | } 58 | 59 | .hover-overlay { 60 | background-color: rgba(0,0,0,0.4); 61 | pointer-events: all; 62 | } 63 | 64 | .text { 65 | font-size: 20px; 66 | 67 | @media (max-width: 500px) { 68 | font-size: 11px;; 69 | } 70 | } -------------------------------------------------------------------------------- /src/scripts/api_parsing.js: -------------------------------------------------------------------------------- 1 | // Old seasons don't have most stats available!! Need to check for this?? 2 | export function parseSearchPlayerStats(data, stat) { 3 | // get the array of stats per game from the 'data' key 4 | let gameStats = data.data; 5 | // get the player object that each gameStat object has, just pull it from the 6 | // first 7 | let playerInfo = gameStats[0].player; 8 | 9 | // player played in this season 10 | if (gameStats.length !== 0) { 11 | let returnData = { 12 | "name": playerInfo.first_name + " " + playerInfo.last_name, 13 | "values": [{ game: 0, total: 0 }] 14 | }; 15 | 16 | let values = returnData.values; 17 | 18 | // sort the stats per game objects by date, beginning of season to end 19 | gameStats.sort(function (a, b) { 20 | return a.game.date > b.game.date ? 1 : a.game.date < b.game.date ? -1 : 0; 21 | }); 22 | 23 | // parse out the specific stat we're looking for 24 | // will need to modify this for stats with percentages, not adding up 25 | // totals for these 26 | gameStats.forEach((game, i) => { 27 | if (i === 0) { 28 | game[stat] !== null ? ( 29 | values.push({ game: i+1, total: game[stat] }) 30 | ) : ( 31 | values.push({ game: i+1, total: 0 }) 32 | ); 33 | } else { 34 | if (game[stat]) { 35 | values.push({ game: i+1, total: values[values.length-1].total + game[stat] }); 36 | } else { 37 | values.push({ game: i+1, total: values[values.length-1].total }); 38 | } 39 | } 40 | }); 41 | 42 | return returnData; 43 | } 44 | // player didn't play in this season 45 | else { 46 | return { 47 | "error": "Player didn't play in this season!" 48 | }; 49 | } 50 | } -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 3 | const outputDir = "./dist"; 4 | 5 | module.exports = { 6 | entry: path.resolve(__dirname, "src", "index.js"), // 7 | output: { 8 | path: path.join(__dirname, outputDir), 9 | filename: "[name].js", 10 | publicPath: "/dist/" 11 | }, 12 | resolve: { 13 | extensions: [".js"] // if we were using React.js, we would include ".jsx" 14 | }, 15 | module: { 16 | rules: [{ 17 | test: /\.js$/, // if we were using React.js, we would use \.jsx?$/ 18 | use: { 19 | loader: "babel-loader", 20 | options: { 21 | presets: ["@babel/preset-env"], 22 | plugins: ["@babel/plugin-proposal-optional-chaining"], 23 | exclude: /node_modules/ 24 | } // if we were using React.js, we would include "react" 25 | } 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: [{ 30 | loader: MiniCssExtractPlugin.loader, 31 | options: { 32 | // you can specify a publicPath here 33 | // by default it uses publicPath in webpackOptions.output 34 | publicPath: "../", 35 | hmr: process.env.NODE_ENV === "development" 36 | } 37 | }, 38 | "css-loader", 39 | "postcss-loader" 40 | ] 41 | }, 42 | { 43 | test: /\.scss/, 44 | use: [{ 45 | loader: MiniCssExtractPlugin.loader, 46 | options: { 47 | // you can specify a publicPath here 48 | // by default it uses publicPath in webpackOptions.output 49 | publicPath: "../", 50 | hmr: process.env.NODE_ENV === "development" 51 | } 52 | }, 53 | "css-loader", 54 | "sass-loader", 55 | "postcss-loader" 56 | ] 57 | } 58 | ] 59 | }, 60 | plugins: [new MiniCssExtractPlugin({ 61 | // Options similar to the same options in webpackOptions.output 62 | // all options are optional 63 | filename: "[name].css", 64 | chunkFilename: "[id].css", 65 | ignoreOrder: false // Enable to remove warnings about conflicting order 66 | }), require("autoprefixer")] 67 | }; -------------------------------------------------------------------------------- /src/styles/animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | from { 3 | opacity: 0; 4 | } 5 | to { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes error-shake { 11 | 0% { transform: translate(30px); } 12 | 20% { transform: translate(-30px); } 13 | 40% { transform: translate(15px); } 14 | 60% { transform: translate(-15px); } 15 | 80% { transform: translate(8px); } 16 | 100% { transform: translate(0px); } 17 | } 18 | 19 | @-webkit-keyframes error-shake { 20 | 0% { -webkit-transform: translate(30px); } 21 | 20% { -webkit-transform: translate(-30px); } 22 | 40% { -webkit-transform: translate(15px); } 23 | 60% { -webkit-transform: translate(-15px); } 24 | 80% { -webkit-transform: translate(8px); } 25 | 100% { -webkit-transform: translate(0px); } 26 | } 27 | 28 | @-moz-keyframes error-shake { 29 | 0% { -moz-transform: translate(30px); } 30 | 20% { -moz-transform: translate(-30px); } 31 | 40% { -moz-transform: translate(15px); } 32 | 60% { -moz-transform: translate(-15px); } 33 | 80% { -moz-transform: translate(8px); } 34 | 100% { -moz-transform: translate(0px); } 35 | } 36 | 37 | @-o-keyframes error-shake { 38 | 0% { -o-transform: translate(30px); } 39 | 20% { -o-transform: translate(-30px); } 40 | 40% { -o-transform: translate(15px); } 41 | 60% { -o-transform: translate(-15px); } 42 | 80% { -o-transform: translate(8px); } 43 | 100% { -o-transform: translate(0px); } 44 | } 45 | 46 | @keyframes graph-glow { 47 | 0% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 48 | 40% { box-shadow: 0 0 15px rgb(175, 42, 42); } 49 | 60% { box-shadow: 0 0 15px rgb(175, 42, 42); } 50 | 100% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 51 | } 52 | 53 | @-webkit-keyframes graph-glow { 54 | 0% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 55 | 40% { box-shadow: 0 0 15px rgb(175, 42, 42); } 56 | 60% { box-shadow: 0 0 15px rgb(175, 42, 42); } 57 | 100% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 58 | } 59 | 60 | @-moz-keyframes graph-glow { 61 | 0% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 62 | 40% { box-shadow: 0 0 15px rgb(175, 42, 42); } 63 | 60% { box-shadow: 0 0 15px rgb(175, 42, 42); } 64 | 100% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 65 | } 66 | 67 | @-o-keyframes graph-glow { 68 | 0% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 69 | 40% { box-shadow: 0 0 15px rgb(175, 42, 42); } 70 | 60% { box-shadow: 0 0 15px rgb(175, 42, 42); } 71 | 100% { box-shadow: 0 0 -5px rgb(175, 42, 42); } 72 | } 73 | 74 | @keyframes color-change { 75 | from { 76 | -webkit-filter: hue-rotate(0deg); 77 | } 78 | to { 79 | -webkit-filter: hue-rotate(-360deg); 80 | } 81 | } 82 | 83 | @-webkit-keyframes color-change { 84 | from { 85 | -webkit-filter: hue-rotate(0deg); 86 | } 87 | to { 88 | -webkit-filter: hue-rotate(-360deg); 89 | } 90 | } -------------------------------------------------------------------------------- /src/styles/modals.scss: -------------------------------------------------------------------------------- 1 | /* MODAL CONTAINER */ 2 | 3 | .no-player-in-season, .duplicate-player, .glossary, .information { 4 | background: rgba(0, 0, 0, 0.6); 5 | left: 0; 6 | position: fixed; 7 | right: 0; 8 | top: 0; 9 | bottom: 0; 10 | width: auto !important; 11 | font-family: 'Signika', sans-serif; 12 | } 13 | 14 | .glossary, .information { 15 | display: none; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | /* MODAL POPUP */ 21 | 22 | .no-player-in-season-popup, .duplicate-player-popup, .glossary-popup, .information-popup { 23 | position: relative; 24 | top: 45%; 25 | margin: 0 auto; 26 | width: fit-content; 27 | background: transparent linear-gradient(rgb(179, 169, 34), rgb(110, 105, 23) 30%,rgb(73, 70, 15) 85%); 28 | height: 12%; 29 | padding: 15px; 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | flex-direction: column; 34 | border-radius: 20px; 35 | box-shadow: 3px 4px 8px -2px white; 36 | color: white; 37 | } 38 | 39 | .no-player-in-season-popup, .duplicate-player-popup { 40 | background: rgb(175, 42, 42); 41 | } 42 | 43 | .glossary-popup, .information-popup { 44 | height: fit-content; 45 | padding-left: 25px; 46 | padding-right: 25px; 47 | top: 0%; 48 | 49 | font-size: 1.1em; 50 | 51 | @media (max-width: 500px) { 52 | font-size: 1em; 53 | } 54 | 55 | @media (max-height: 500px) { 56 | font-size: 0.8em; 57 | } 58 | } 59 | 60 | .information-popup { 61 | /* align-items: flex-start; */ 62 | justify-content: flex-start; 63 | max-height: 60%; 64 | width: 40%; 65 | overflow: hidden; 66 | min-height: fit-content; 67 | 68 | @media (max-width: 500px) { 69 | width: 60%; 70 | } 71 | } 72 | 73 | .inner-information-popup { 74 | display: flex; 75 | flex-direction: column; 76 | overflow-y: auto; 77 | } 78 | 79 | .inner-information-popup::-webkit-scrollbar { 80 | background-color: transparent; 81 | width: 4px; 82 | } 83 | 84 | .inner-information-popup::-webkit-scrollbar-thumb { 85 | background-color: #7a0025; 86 | border-radius: 10px; 87 | } 88 | 89 | .info-container { 90 | display: flex; 91 | flex-direction: column; 92 | width: 100%; 93 | margin-top: 1em; 94 | } 95 | 96 | .glossary-popup h1, .information-popup h1 { 97 | font-size: 1.5em; 98 | } 99 | 100 | .glossary-popup h1 { 101 | margin-bottom: 2vh; 102 | } 103 | 104 | /* MODAL BUTTON */ 105 | 106 | .no-player-in-season-button, .duplicate-player-button, .glossary-button, .information-button { 107 | border: none; 108 | padding: 0; 109 | background: none; 110 | position: absolute; 111 | top: 10px; 112 | right: 15px; 113 | font-size: 25px; 114 | outline: none; 115 | color: rgb(55, 194, 209); 116 | } 117 | 118 | .no-player-in-season-button:hover, .duplicate-player-button:hover, .glossary-button:hover, .information-button:hover { 119 | cursor: pointer; 120 | color: rgb(9, 149, 165); 121 | } 122 | 123 | /* GLOSSARY LIST */ 124 | 125 | .glossary-list-item { 126 | display: flex; 127 | justify-content: space-between; 128 | margin-bottom: 1vh; 129 | } 130 | 131 | .abbr { 132 | margin-right: 4vw; 133 | color: yellow; 134 | } 135 | 136 | .full { 137 | color: black; 138 | } 139 | 140 | /* INFORMATION DETAILS */ 141 | 142 | .information-popup h1 { 143 | align-self: center; 144 | } 145 | 146 | .information-popup h2 { 147 | font-size: 1.1em; 148 | color: black; 149 | text-shadow: 1px 1px 3px yellow; 150 | } 151 | 152 | .information-popup .h2-container { 153 | display: flex; 154 | align-self: center; 155 | align-items: center; 156 | flex-direction: column; 157 | margin-bottom: 0.4vh; 158 | } 159 | 160 | .information-popup .h2-container::after { 161 | content: ''; 162 | display: inline-block; 163 | margin: 0.4em 0; 164 | width: 140%; 165 | border: 1px groove lightgreen; 166 | } 167 | 168 | .about { 169 | list-style: none; 170 | } 171 | 172 | .steps-list { 173 | list-style-position: inside; 174 | } 175 | 176 | .steps-list.one { 177 | list-style-type: decimal; 178 | } 179 | 180 | .steps-list.two { 181 | list-style-type: lower-alpha; 182 | } 183 | 184 | .steps-list li { 185 | margin-bottom: 0.5vh; 186 | } 187 | 188 | .steps-list.two i { 189 | color: #f5d90a; 190 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NBA Stat Race 2 | 3 | An NBA stats visualizer to plot the route a given stat took over the course of a given season for the selected players, leading up to the "winner" in that stat by season's end. 4 | 5 | ### Background and Overview 6 | 7 | NBA Stat Race (NBASR) is an example of sports data being turned onto its heels to be used in ways that people could not see otherwise in non-visual ways. Throughout the course of an NBA season, players go through ups and downs while trying to be competitive with their fellow players. By the time the season comes to an end, one player has either crowned himself as the king of a stat, or the race to be the best turned out to be a lot closer than expected. But what if you wanted to see how the race played out between specific players, and whether it was always a runaway or one player took over others in the final days of the season? And what if instead of looking at mainstream statistics you wanted to look at a more peculiar stat race? These are the visualizations NBASR provides, so that as a fan, you too can now follow along a player as they try to beat out their colleagues in a stat race. 8 | 9 | ### Functionality and MVPs 10 | 11 | **With NBA Stat Race, users will be able to:** 12 | 13 | 1. Select the season they wish to see the stat race in 14 | 2. Select the stat they want the race to be in 15 | 3. Select the players they want to compete in the stat race 16 | 4. See a graph which plots out, game by game, the cumulative totals up to that game for the given player 17 | 18 | **In addition, this project will include:** 19 | 20 | 1. A Glossary section/modal that lists out the full forms of stat abbreviations 21 | 2. A production README 22 | 23 | ### Wireframes 24 | 25 | This app will consist of a single page with multiple different elements. There will be a drop-down to select a season, a drop-down to select the stat, and on the right side of the page on this row there will be a button to open the glossary modal. On the next row we have an input field to search for and add players. The players who have been added will be shown in a bar to the right of the input field, with the bar updating as players get added. Underneath these elements will be the rendered line graph which will be the center of the entire page. Lastly there is a footer at the bottom, which will contain links to my GitHub, LinkedIn, and any other relevant links. 26 | 27 | ***Main Page*** 28 | 29 | ![main_wireframe] 30 | 31 | [main_wireframe]: https://raw.githubusercontent.com/MannanK/NBA-Stat-Race/master/src/assets/main_wireframe.png 32 | 33 | ***Glossary Modal*** 34 | 35 | ![modal_wireframe] 36 | 37 | [modal_wireframe]: https://raw.githubusercontent.com/MannanK/NBA-Stat-Race/master/src/assets/modal_wireframe.png 38 | 39 | ### Architecture and Technology 40 | 41 | NBA Stat Race is built with: 42 | 43 | 1. `JavaScript` for retrieving and parsing data from the MySportsFeeds API 44 | 2. `D3`, `SVG`, `HTML`, & `CSS` for visualizing the data and making the app interactive/appealing 45 | 3. `Webpack` & `Babel` to bundle js files 46 | 47 | In addition to the entry file, NBASR will be split up into: 48 | 49 | 1. `apiParsing.js`: To make HTTP requests to the API and parse back the data 50 | 2. `graph.js`: To make and render the graph to the screen 51 | 52 | ### Implementation Timeline 53 | 54 | **Day 1** 55 | 56 | Set up the project skeleton, figure out and install all necessary node modules, get Webpack running, get a basic entry file going, make a rough skeleton of the entire main page, start writing out a skeleton for the other two script files 57 | 58 | **Day 2** 59 | 60 | Figure out completely how to use the MySportsFeeds API and finish as much of `apiParsing.js` as possible, start using D3 to implement the rendering of the graph 61 | 62 | **Day 3** 63 | 64 | Dedicate this day to learning D3 and writing out as much of `graph.js` as possible, modify parsing of data returned from the API if needed 65 | 66 | **Day 4** 67 | 68 | Continue working on making the graph look visually appealing, create the Glossary modal, work on bonus features if there's time 69 | 70 | **Day 5** 71 | 72 | Finish the Glossary modal, make the footer, finish CSS for the app, work on bonus features if there's time 73 | 74 | ### Bonus Features 75 | 76 | 1. Ability to see the top 10 leaders league-wide in the selected season and stat 77 | 2. Since the API is very robust, the site can be expanded to also include MLB stats 78 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | // WHOLE PAGE CONTENT 2 | body { 3 | background-color: #1c1c1c; 4 | font-family: 'Playfair Display SC', serif; 5 | 6 | animation: fade-in 1500ms ease-in; 7 | } 8 | 9 | .content { 10 | display: flex; 11 | flex-direction: column; 12 | background-color: #1c1c1c; 13 | width: 100%; 14 | padding: 5vh; 15 | box-sizing: border-box; 16 | min-width: fit-content; 17 | } 18 | 19 | // SITE HEADER 20 | 21 | .site-header { 22 | align-self: center; 23 | font-size: 40px; 24 | margin-bottom: 35px; 25 | color: #f35626; 26 | background-image: -webkit-linear-gradient(20deg, #f3d426, #1bca1b); 27 | background-clip: inherit; 28 | -webkit-background-clip: text; 29 | -webkit-text-fill-color: transparent; 30 | animation: color-change 5s infinite linear; 31 | -webkit-animation: color-change 5s infinite linear; 32 | } 33 | 34 | // TOP ROW, BOTTOM ROW 35 | 36 | .top-row, .bottom-row { 37 | display: flex; 38 | justify-content: space-between; 39 | height: 3vh; 40 | margin-bottom: 35px; 41 | } 42 | 43 | .top-row *, .bottom-row * { 44 | border-radius: 7px; 45 | } 46 | 47 | // LEFT TOP ROW 48 | 49 | .left-top-row { 50 | display: flex; 51 | width: 35%; 52 | justify-content: space-between; 53 | } 54 | 55 | // SEASON DROPDOWN, STAT DROPDOWN 56 | 57 | #season-dropdown, #stat-dropdown { 58 | width: 45%; 59 | cursor: pointer; 60 | border: none; 61 | padding: 0px 6px; 62 | color: white; 63 | box-shadow: 0px 0px 4px white; 64 | 65 | transition: all 300ms ease-in-out; 66 | } 67 | 68 | #season-dropdown { 69 | background: transparent linear-gradient(to right, rgba(201, 190, 38, 0.671), #000); 70 | } 71 | 72 | #stat-dropdown { 73 | background: transparent linear-gradient(to left, rgba(201, 190, 38, 0.671), #000); 74 | } 75 | 76 | #season-dropdown:hover, #stat-dropdown:hover { 77 | transform: scale(1.05); 78 | } 79 | 80 | option { 81 | background-color: gray; 82 | } 83 | 84 | .not-selected-error { 85 | background: rgb(175, 42, 42) !important; 86 | color: rgb(255, 225, 0) !important; 87 | } 88 | 89 | .not-selected-error.animation { 90 | animation: error-shake 400ms linear; 91 | -moz-animation: error-shake 400ms linear; 92 | -o-animation: error-shake 400ms linear; 93 | -webkit-animation: error-shake 400ms linear; 94 | } 95 | 96 | // RIGHT TOP ROW 97 | 98 | .right-top-row { 99 | display: flex; 100 | width: 5%; 101 | min-width: fit-content; 102 | } 103 | 104 | // GLOSSARY & INFORMATION BUTTON 105 | 106 | #glossary-button, #information-button { 107 | width: 100%; 108 | cursor: pointer; 109 | border: none; 110 | padding: 0px 7px; 111 | box-shadow: 0px 0px 4px white; 112 | color: white; 113 | 114 | transition: all 300ms ease-in-out; 115 | } 116 | 117 | #information-button { 118 | background: transparent linear-gradient(to right, rgba(224, 40, 21, 0.671), #000); 119 | } 120 | 121 | #glossary-button { 122 | background: transparent linear-gradient(to left, rgba(224, 40, 21, 0.671), #000); 123 | } 124 | 125 | #glossary-button:hover, #information-button:hover { 126 | transform: scale(1.05); 127 | } 128 | 129 | #glossary-button { 130 | margin-left: 8%; 131 | } 132 | 133 | // SEARCH PLAYERS INPUT & DROPDOWN 134 | 135 | .search-players-input-container { 136 | display: flex; 137 | flex-direction: column; 138 | width: 19.5%; 139 | margin-right: 7vw; 140 | position: relative; 141 | box-shadow: 0px 0px 4px white; 142 | height: 4vh; 143 | align-self: center; 144 | } 145 | 146 | #search-players-input { 147 | background: transparent linear-gradient(to right, rgba(201, 190, 38, 0.671), #000); 148 | min-height: 100%; 149 | box-sizing: border-box; 150 | border: none; 151 | padding: 12px; 152 | background-color: #1c1c1c; 153 | color: white; 154 | } 155 | 156 | #search-players-input::-webkit-input-placeholder { 157 | color: white; 158 | } 159 | 160 | #search-players-input::-moz-placeholder { 161 | color: white; 162 | } 163 | 164 | #search-players-input:-ms-input-placeholder { 165 | color: white; 166 | } 167 | 168 | #search-players-input:-moz-placeholder { 169 | color: white; 170 | } 171 | 172 | #player-dropdown { 173 | background-color: lightgray; 174 | animation-name: fade-in; 175 | animation-duration: 500ms; 176 | z-index: 1; 177 | cursor: pointer; 178 | } 179 | 180 | #player-dropdown li { 181 | min-height: 4vh; 182 | padding: 2px 8px; 183 | border-bottom: 1px solid black; 184 | display: flex; 185 | align-items: center; 186 | overflow: hidden; 187 | box-sizing: border-box; 188 | } 189 | 190 | // PLAYER NAMES 191 | 192 | .player-names-container { 193 | 194 | background: transparent linear-gradient(to left, rgba(201, 190, 38, 0.671), #000); 195 | width: 75.5%; 196 | min-height: 5vh; 197 | max-height: fit-content; 198 | display: flex; 199 | color: white; 200 | align-items: center; 201 | padding: 0 1vw; 202 | box-sizing: border-box; 203 | align-self: center; 204 | justify-content: space-evenly; 205 | flex-wrap: wrap; 206 | box-shadow: 0px 0px 4px white; 207 | 208 | @media only screen and (max-device-width: 500px) { 209 | font-size: 11px; 210 | } 211 | } 212 | 213 | .player-name-container { 214 | margin-right: 1vw; 215 | margin-top: 1vh; 216 | margin-bottom: 1vh; 217 | } 218 | 219 | .player-name { 220 | margin-right: 0.4vw; 221 | } 222 | 223 | .remove-player-button { 224 | border: none; 225 | background: none; 226 | padding: 0; 227 | outline: none; 228 | color: rgb(245, 217, 10); 229 | font-size: 14px; 230 | 231 | @media only screen and (max-device-width: 500px) { 232 | font-size: 9px; 233 | } 234 | } 235 | 236 | .remove-player-button:hover { 237 | cursor: pointer; 238 | color: rgb(197, 176, 16); 239 | } 240 | 241 | // FOOTER 242 | 243 | footer { 244 | display: flex; 245 | justify-content: center; 246 | align-items: center; 247 | font-size: 2.5em; 248 | 249 | @media only screen and (max-device-width: 500px) { 250 | font-size: 2em; 251 | } 252 | } 253 | 254 | footer div { 255 | margin-right: 1.5vw; 256 | } 257 | 258 | footer a { 259 | color: rgb(218, 194, 19); 260 | } 261 | 262 | footer a:hover { 263 | color: rgb(192, 46, 46); 264 | } 265 | 266 | footer div:last-child { 267 | margin-right: 0; 268 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | NBA Stat Race 17 | 18 | 19 | 20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 | 28 |
29 | 30 |
31 | 34 | 37 |
38 |
39 | 40 |
41 |
42 | 43 |
44 | 45 |
46 | 47 |
48 |
49 | 50 |
51 |
52 | 53 |
54 |
55 |
56 |

Information

57 | 58 |
59 |
60 |

Welcome

61 |
62 |
63 |

Welcome to NBA Stat Race! Our stats visualizer will plot for you the route a given stat took over the course of a 64 | given season for the selected players, leading up to the "winner" in that stat by season's end. As a fan, you too 65 | can now follow along a player as they try to beat out their colleagues in a stat race.

66 |
67 |
68 | 69 |
70 |
71 |

How to Use

72 |
73 |
74 |
    75 |
  1. Select the season you want to follow
  2. 76 |
  3. Select the stat you want to track
  4. 77 |
  5. Search for the player(s) you want participating in the race (first and/or last names)
  6. 78 |
  7. Click on the player's name and he will then be added to the graph
  8. 79 |
  9. Hover over the graph to see the cumulative totals per player up until the desired game
  10. 80 |
81 |
82 |
83 |
    84 |
  1. To remove a player, click on the next to their names
  2. 85 |
  3. If you change any of the fields, the graph and fields will glow red to let you know the next graph will be 86 | reset with the corresponding stats
  4. 87 |
88 |
89 |
90 | 91 |
92 |
93 |
94 | 95 |
96 |
97 |

Glossary

98 |
    99 |
  • PTSPoints
  • 100 |
  • FGMField Goals Made
  • 101 |
  • FGAField Goals Attempted
  • 102 |
  • 3PM3 Pointers Made
  • 103 |
  • 3PA3 Pointers Attempted
  • 104 |
  • FTMFree Throws Made
  • 105 |
  • FTAFree Throws Attempted
  • 106 |
  • ASTAssists
  • 107 |
  • REBRebounds
  • 108 |
  • DREBDefensive Rebounds
  • 109 |
  • OREBOffensive Rebounds
  • 110 |
  • STLSteals
  • 111 |
  • BLKBlocks
  • 112 |
  • TOTurnovers
  • 113 |
  • PFPersonal Fouls
  • 114 |
115 | 116 |
117 |
118 | 119 | 125 |
126 | 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /src/scripts/graph.js: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash'; 2 | 3 | const margin = { top: 50, right: 50, bottom: 50, left: 50 }; 4 | 5 | function resizingFunction(svg) { 6 | let width = parseInt(svg.style('width'), 10); 7 | let height = parseInt(svg.style('height'), 10); 8 | let aspectRatio = width / height; 9 | 10 | svg.attr('viewBox', `0 0 ${width} ${height}`) 11 | .attr('preserveAspectRatio', 'xMinYMin') 12 | .call(resize); 13 | 14 | d3.select(window).on('resize', resize); 15 | 16 | function resize() { 17 | let docGraphWidth = document.getElementById("graph-container").clientWidth; 18 | 19 | svg.attr('width', docGraphWidth); 20 | svg.attr('height', Math.round(docGraphWidth / aspectRatio)); 21 | document.getElementById("graph-container").style.height = `${Math.round(docGraphWidth / aspectRatio)}px`; 22 | } 23 | } 24 | 25 | export function makeGraph(data, makeHover) { 26 | if (document.getElementsByTagName("svg").length !== 0) { 27 | updateGraph(data); 28 | } else { 29 | let docGraphWidth = document.getElementById("graph-container").clientWidth; 30 | let docGraphHeight = document.getElementById("graph-container").clientHeight; 31 | 32 | let width = docGraphWidth - margin.left - margin.right; 33 | let height = docGraphHeight - margin.top - margin.bottom; 34 | 35 | // Margin convention and make the graph resize as the window resizes 36 | // From now on, all subsequent code can just use 'width' and 'height' 37 | let svg = d3.select('#graph-container').append("svg") 38 | .attr('width', docGraphWidth) 39 | .attr('height', docGraphHeight); 40 | // .call(resizingFunction); 41 | 42 | let g = svg.append("g") 43 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); 44 | 45 | // make the hover info/tooltip container 46 | d3.select('#graph-container') 47 | .append("g") 48 | .attr("class", "hover-info-container") 49 | .style('display', 'none'); 50 | 51 | // make the hover line that will show up as the mouse moves 52 | g.append("line") 53 | .attr("class", "hover-line") 54 | .style("shape-rendering", "crispEdges"); 55 | 56 | let linesContainer = g.append('g') 57 | .attr('class', 'lines-container'); 58 | 59 | let xScale = d3.scaleLinear() 60 | .domain([0, 82]) 61 | .range([0, width]); 62 | 63 | // change these two to g.append if want axis in front of the lines 64 | // make the x axis group 65 | linesContainer.append("g") 66 | .attr("transform", "translate(0," + height + ")") 67 | .attr('class', "x-axis") 68 | .transition() 69 | .duration(750) 70 | .call(d3.axisBottom(xScale)); 71 | 72 | // make the y axis group 73 | linesContainer.append("g") 74 | .attr('class', "y-axis") 75 | .append("text") 76 | .attr("fill", "#000") 77 | .attr("transform", "rotate(-90)") 78 | .attr("y", 10) 79 | .attr("dy", "0.8em") 80 | .text("Total") 81 | .attr("fill", "white"); 82 | 83 | updateGraph(data, makeHover); 84 | } 85 | } 86 | 87 | export function updateGraph(data, makeHover) { 88 | let svg = d3.selectAll('svg'); 89 | 90 | let docGraphWidth = document.getElementById("graph-container").clientWidth; 91 | let docGraphHeight = document.getElementById("graph-container").clientHeight; 92 | 93 | let width = docGraphWidth - margin.left - margin.right; 94 | let height = docGraphHeight - margin.top - margin.bottom; 95 | 96 | let maxTotal = Math.max(...data.map(obj => { 97 | return Math.max(...Object.values(obj.values).map(player => { 98 | return player.total; 99 | })); 100 | })); 101 | 102 | let xScale = d3.scaleLinear() 103 | .domain([0, 82]) 104 | .range([0, width]); 105 | 106 | let yScale = d3.scaleLinear() 107 | .domain([0, maxTotal + 10]) 108 | .range([height, 0]); 109 | 110 | svg.selectAll(".y-axis") 111 | .transition() 112 | .duration(750) 113 | .call(d3.axisLeft(yScale)); 114 | 115 | let line = d3.line() 116 | .x(function (d) { return xScale(d.game); }) 117 | .y(function (d) { return yScale(d.total); }) 118 | .curve(d3.curveLinear); 119 | 120 | let color = d3.scaleOrdinal(d3.schemeCategory10); 121 | 122 | let linesContainer = svg.selectAll('.lines-container'); 123 | let paths = linesContainer.selectAll(`.line`).data(data); 124 | 125 | paths.exit().remove(); 126 | 127 | // for each object (player) in the data array, make the lines 128 | // make the group element for the line 129 | // put the actual line on the screen 130 | paths 131 | .enter() 132 | .append("g") 133 | .attr("class", "line-container") 134 | .append("path") 135 | .attr("class", "line") 136 | .attr('d', d => line(d.values)) 137 | .each(function (d) { d.totalLength = this.getTotalLength(); }) 138 | .attr("stroke-dasharray", function (d) { return d.totalLength + " " + d.totalLength; }) 139 | .attr("stroke-dashoffset", function (d) { return d.totalLength; }) 140 | .merge(paths) 141 | .attr("stroke", (d, i) => color(i)) 142 | .transition() 143 | .duration(750) 144 | .attr('d', d => line(d.values)) 145 | .transition() 146 | .duration(3000) 147 | .ease(d3.easeLinear) 148 | .attr("stroke-dashoffset", 0) 149 | .attr("fill", "none"); 150 | 151 | // let names = data.map(player => player.name); 152 | // hoverInfo(data, names, color, xScale, yScale); 153 | 154 | d3.selectAll('.hover-overlay').remove(); 155 | let hoverOverlay = svg.append("rect") 156 | .attr("class", "hover-overlay") 157 | .attr("x", margin.left) 158 | .attr("y", margin.bottom) 159 | .attr('opacity', 0) 160 | .attr("width", width) 161 | .attr("height", height) 162 | 163 | if (makeHover) { 164 | // const hoverOverlay = d3.selectAll('.hover-overlay'); 165 | const hoverInfoContainer = d3.selectAll('.hover-info-container'); 166 | const hoverLine = d3.selectAll(".hover-line"); 167 | 168 | let newData = data.map(player => merge({}, player)); 169 | 170 | data.forEach((player, idx) => { 171 | let numGamesPlayed = player.values.length - 1; 172 | 173 | if (numGamesPlayed != 82) { 174 | let lastGame = numGamesPlayed + 1; 175 | let total = player.values[numGamesPlayed].total; 176 | 177 | for (let i = lastGame; i <= 82; i++) { 178 | let obj = { 179 | game: lastGame, 180 | total 181 | } 182 | 183 | newData[idx].values.push(obj); 184 | lastGame++; 185 | } 186 | } 187 | 188 | newData[idx].originalIndex = idx; 189 | }); 190 | 191 | d3.select(".hover-overlay") 192 | .on('mousemove', showHoverInfo.bind(this, newData, xScale, yScale, hoverOverlay, hoverInfoContainer, hoverLine, width, height, color)) 193 | .on('mouseout', hideHoverInfo); 194 | } 195 | } 196 | 197 | function hideHoverInfo() { 198 | const hoverInfoContainer = d3.select('.hover-info-container'); 199 | const hoverLine = d3.select(".hover-line"); 200 | 201 | if (hoverInfoContainer) hoverInfoContainer.style('display', 'none'); 202 | if (hoverLine) hoverLine.attr('stroke', 'none'); 203 | } 204 | 205 | function showHoverInfo(data, xScale, yScale, hoverOverlay, hoverInfoContainer, hoverLine, width, height, color) { 206 | const bisector = d3.bisector(function (d) { return d.game; }).left; 207 | const overlayNode = hoverOverlay.node(); 208 | const mousePos = d3.mouse(overlayNode); 209 | 210 | let x = xScale.invert(mousePos[0] - 50); 211 | const game = bisector(data[0].values, x, 1); 212 | const currentDimensions = overlayNode.getBoundingClientRect(); 213 | 214 | if (game >= 0 && game <= 82) { 215 | data.sort((player1, player2) => { 216 | return ( 217 | player2.values 218 | .find(obj => obj.game == game).total - 219 | player1.values 220 | .find(obj => obj.game == game).total 221 | ); 222 | }) 223 | 224 | hoverLine 225 | .attr('stroke', 'white') 226 | .attr('x1', xScale(game)) 227 | .attr('x2', xScale(game)) 228 | .attr('y1', 0) 229 | .attr('y2', height); 230 | 231 | hoverInfoContainer 232 | .text("Game: " + game) 233 | .style('color', "white") 234 | .style('display', 'flex') 235 | .selectAll() 236 | .data(data) 237 | .enter() 238 | .append('text') 239 | .style('color', (d) => color(d.originalIndex)) 240 | .text(d => d.name + ': ' + d.values.find(h => h.game == game).total); 241 | 242 | let hoverInfoContainerWidth = hoverInfoContainer.node().offsetWidth; 243 | 244 | let left = (mousePos[0] + hoverInfoContainerWidth) > width ? ( 245 | ((mousePos[0] - hoverInfoContainerWidth) - 20) * (currentDimensions.width / width) 246 | ) : ( 247 | (mousePos[0] + 30) * (currentDimensions.width / width) 248 | ); 249 | 250 | hoverInfoContainer 251 | .style('left', `${left}px`) 252 | .style('top', `${(mousePos[1] - 15) * (currentDimensions.height / height)}px`) 253 | } 254 | 255 | // xScale(game) > (width - width/4) 256 | // ? d3.selectAll(".hover-info-container") 257 | // .attr("text-anchor", "end") 258 | // .attr("dx", -10) 259 | // : d3.selectAll(".hover-info-container") 260 | // .attr("text-anchor", "start") 261 | // .attr("dx", 10) 262 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import "./styles/reset.scss"; 2 | import "./styles/animations.scss"; 3 | import "./styles/index.scss"; 4 | import "./styles/graph.scss"; 5 | import "./styles/modals.scss"; 6 | 7 | import { makeGraph, updateGraph } from './scripts/graph'; 8 | import { searchPlayers, searchPlayerStats } from './scripts/api_util'; 9 | import { debounce } from 'lodash'; 10 | 11 | const stats = [ 12 | { 'pts': "PTS" }, 13 | { 'fgm': "FGM" }, 14 | { 'fga': "FGA" }, 15 | { 'fg3m': "3PM" }, 16 | { 'fg3a': "3PA" }, 17 | { 'ftm': "FTM" }, 18 | { 'fta': "FTA" }, 19 | { 'ast': "AST" }, 20 | { 'reb': "REB" }, 21 | { 'dreb': "DREB" }, 22 | { 'oreb': "OREB" }, 23 | { 'stl': "STL" }, 24 | { 'blk': "BLK" }, 25 | { 'turnover': "TO" }, 26 | { 'pf': "PF" } 27 | // { 'fg_pct': "FG%" }, 28 | // { 'fg3_pct': "3PT%" }, 29 | // { 'ft_pct': "FT%" } 30 | ]; 31 | 32 | let data = []; 33 | let resetData = false; 34 | let previousSeasonVal = 0; 35 | let previousStatVal = 0; 36 | 37 | // make the season dropdown menu 38 | function makeSeasonDropdown() { 39 | const dropdownEl = document.getElementById("season-dropdown"); 40 | 41 | let startYear = 1990; 42 | let end = new Date().getFullYear(); 43 | let options = ""; 44 | 45 | for (let year = startYear; year < end; year++) { 46 | options += `"; 47 | } 48 | 49 | dropdownEl.innerHTML = options; 50 | 51 | dropdownEl.onclick = function () { 52 | dropdownEl.classList.remove("not-selected-error"); 53 | }; 54 | } 55 | 56 | // make the stat dropdown menu 57 | function makeStatDropdown() { 58 | const dropdownEl = document.getElementById("stat-dropdown"); 59 | 60 | let options = ""; 61 | 62 | stats.forEach(stat => { 63 | let key = Object.keys(stat)[0]; 64 | let val = Object.values(stat)[0]; 65 | 66 | options += `"; 67 | }); 68 | 69 | dropdownEl.innerHTML = options; 70 | 71 | dropdownEl.onclick = function () { 72 | dropdownEl.classList.remove("not-selected-error"); 73 | }; 74 | } 75 | 76 | function checkGraphGlow() { 77 | const seasonDropdown = document.getElementById("season-dropdown"); 78 | const statDropdown = document.getElementById("stat-dropdown"); 79 | const graphContainer = document.getElementById("graph-container"); 80 | const playerNamesContainer = document.getElementsByClassName("player-names-container")[0]; 81 | const lineContainer = document.getElementsByClassName("line-container"); 82 | 83 | seasonDropdown.onchange = function () { 84 | if (seasonDropdown.selectedIndex === previousSeasonVal && statDropdown.selectedIndex === previousStatVal) { 85 | graphContainer.classList.remove("graph-glow"); 86 | playerNamesContainer.classList.remove("graph-glow"); 87 | resetData = false; 88 | } else { 89 | if (lineContainer.length !== 0) { 90 | graphContainer.classList.add("graph-glow"); 91 | playerNamesContainer.classList.add("graph-glow"); 92 | resetData = true; 93 | } 94 | } 95 | }; 96 | 97 | statDropdown.onchange = function () { 98 | if (statDropdown.selectedIndex === previousStatVal && seasonDropdown.selectedIndex === previousSeasonVal) { 99 | graphContainer.classList.remove("graph-glow"); 100 | playerNamesContainer.classList.remove("graph-glow"); 101 | resetData = false; 102 | } else { 103 | if (lineContainer.length !== 0) { 104 | graphContainer.classList.add("graph-glow"); 105 | playerNamesContainer.classList.add("graph-glow"); 106 | resetData = true; 107 | } 108 | } 109 | }; 110 | } 111 | 112 | // check if the user has entered valid input 113 | // if yes, pass off the input to the debouncedSearch 114 | // if not, remove the dropdown element from the DOM 115 | function handlePlayerInput(e) { 116 | let inputVal = e.currentTarget.value; 117 | 118 | if (inputVal !== "" && inputVal.length > 1) { 119 | debouncedSearch(inputVal); 120 | } else { 121 | if (document.getElementById("player-dropdown")) { 122 | document.getElementById("player-dropdown").remove(); 123 | } 124 | } 125 | } 126 | 127 | // user made a valid input, pass off to searchPlayers to send the API request 128 | // on return, make the dropdown containing the results 129 | function debouncedSearch(input) { 130 | searchPlayers(input).then(searchResults => { 131 | makePlayerDropdown(searchResults); 132 | }); 133 | } 134 | 135 | // with the results gotten from the API requests, make the dropdown element 136 | function makePlayerDropdown(searchResults) { 137 | const playerInputContainer = document.getElementsByClassName("search-players-input-container")[0]; 138 | 139 | let playerList = document.getElementById("player-dropdown"); 140 | 141 | // if the dropdown currently exists (user added more letters), empty it out 142 | if (playerList) { 143 | playerList.innerHTML = ""; 144 | } 145 | // otherwise, make a new ul for the dropdown 146 | else { 147 | playerList = document.createElement("ul"); 148 | playerList.setAttribute("id", "player-dropdown"); 149 | } 150 | 151 | // one by one create a new list element for each player 152 | searchResults.forEach(({ first_name, last_name, id }) => { 153 | let playerName = first_name + " " + last_name; 154 | 155 | let playerItem = document.createElement("li"); 156 | playerItem.classList.add("player-item"); 157 | playerItem.setAttribute("id", id); 158 | playerItem.onclick = handlePlayerClick; 159 | playerItem.innerHTML = playerName; 160 | playerList.append(playerItem); 161 | }); 162 | 163 | // add the list items to the dropdown 164 | playerInputContainer.append(playerList); 165 | } 166 | 167 | function handlePlayerClick(e) { 168 | const seasonDropdown = document.getElementById("season-dropdown"); 169 | const statDropdown = document.getElementById("stat-dropdown"); 170 | const playerDropdown = document.getElementById("player-dropdown"); 171 | const playerInputEl = document.getElementById("search-players-input"); 172 | const graphContainer = document.getElementById("graph-container"); 173 | const playerNamesContainer = document.getElementsByClassName("player-names-container")[0]; 174 | 175 | if (seasonDropdown.selectedIndex === previousSeasonVal && statDropdown.selectedIndex === previousStatVal) { 176 | for (let i = 0; i < data.length; i++) { 177 | if (data[i].name === e.target.textContent) { 178 | playerDropdown.remove(); 179 | playerInputEl.value = ""; 180 | 181 | makeModal("duplicate-player", e.target.textContent); 182 | return; 183 | } 184 | } 185 | } 186 | 187 | if (seasonDropdown.selectedIndex > 0 && statDropdown.selectedIndex > 0) { 188 | let seasonVal = seasonDropdown.options[seasonDropdown.selectedIndex].value; 189 | let statVal = statDropdown.options[statDropdown.selectedIndex].value; 190 | let playerVal = e.target.id; 191 | 192 | searchPlayerStats(seasonVal, statVal, playerVal) 193 | .then(searchResults => { 194 | if (searchResults !== null) { 195 | if (resetData) { 196 | data = []; 197 | resetData = false; 198 | } 199 | 200 | playerDropdown.remove(); 201 | previousSeasonVal = seasonDropdown.selectedIndex; 202 | previousStatVal = statDropdown.selectedIndex; 203 | graphContainer.classList.remove("graph-glow"); 204 | playerNamesContainer.classList.remove("graph-glow"); 205 | playerInputEl.value = ""; 206 | 207 | data.push(searchResults); 208 | updateGraph(data, true); 209 | 210 | updatePlayerNames(); 211 | } else { 212 | let playerName = e.target.innerHTML; 213 | playerDropdown.remove(); 214 | playerInputEl.value = ""; 215 | 216 | makeModal("no-player-in-season", playerName); 217 | } 218 | }); 219 | } else { 220 | if (seasonDropdown.selectedIndex === 0) { 221 | seasonDropdown.classList.add("not-selected-error", "animation"); 222 | setTimeout(() => { 223 | seasonDropdown.classList.remove("animation"); 224 | }, 400); 225 | } 226 | 227 | if (statDropdown.selectedIndex === 0) { 228 | statDropdown.classList.add("not-selected-error", "animation"); 229 | setTimeout(() => { 230 | statDropdown.classList.remove("animation"); 231 | }, 400); 232 | } 233 | 234 | if (playerDropdown) { 235 | playerDropdown.style.display = "none"; 236 | } 237 | } 238 | } 239 | 240 | function updatePlayerNames() { 241 | const playerNamesContainer = document.getElementsByClassName("player-names-container")[0]; 242 | while (playerNamesContainer.firstChild) { 243 | playerNamesContainer.removeChild(playerNamesContainer.firstChild); 244 | } 245 | 246 | data.forEach((player, i) => { 247 | let playerNameContainer = document.createElement("div"); 248 | playerNameContainer.classList.add("player-name-container"); 249 | playerNameContainer.innerHTML = `${player.name}`; 250 | 251 | const removePlayerButton = document.createElement('button'); 252 | removePlayerButton.setAttribute("id", `player-name-${i}`); 253 | removePlayerButton.className = "remove-player-button"; 254 | removePlayerButton.innerHTML = ''; 255 | removePlayerButton.onclick = handleRemovePlayer; 256 | 257 | playerNameContainer.appendChild(removePlayerButton); 258 | playerNamesContainer.append(playerNameContainer); 259 | }); 260 | } 261 | 262 | function handleRemovePlayer(e) { 263 | let idx = e.target.parentNode.id.split("-")[2]; 264 | data.splice(idx, 1); 265 | 266 | if (data.length !== 0) { 267 | updateGraph(data, true); 268 | } else { 269 | updateGraph(data, false); 270 | } 271 | 272 | updatePlayerNames(); 273 | } 274 | 275 | window.data = data; 276 | 277 | function makeModal(type, playerName) { 278 | const modalBackground = document.createElement('div'); 279 | const modalPopup = document.createElement('section'); 280 | const popupText = document.createElement('strong'); 281 | const popupButton = document.createElement('button'); 282 | 283 | switch (type) { 284 | case "no-player-in-season": 285 | modalBackground.className = type; 286 | document.body.appendChild(modalBackground); 287 | 288 | modalPopup.className = "no-player-in-season-popup"; 289 | 290 | popupText.textContent = `${playerName} didn't play in this season!`; 291 | modalPopup.appendChild(popupText); 292 | 293 | popupButton.className = "no-player-in-season-button"; 294 | popupButton.innerHTML = ''; 295 | modalPopup.appendChild(popupButton); 296 | 297 | popupButton.onclick = function () { 298 | modalBackground.remove(); 299 | }; 300 | 301 | modalBackground.appendChild(modalPopup); 302 | 303 | break; 304 | case "duplicate-player": 305 | modalBackground.className = type; 306 | document.body.appendChild(modalBackground); 307 | 308 | modalPopup.className = "duplicate-player-popup"; 309 | 310 | popupText.textContent = `You have already added ${playerName}!`; 311 | modalPopup.appendChild(popupText); 312 | 313 | popupButton.className = "duplicate-player-button"; 314 | popupButton.innerHTML = ''; 315 | modalPopup.appendChild(popupButton); 316 | 317 | popupButton.onclick = function () { 318 | modalBackground.remove(); 319 | }; 320 | 321 | modalBackground.appendChild(modalPopup); 322 | 323 | break; 324 | case "glossary": 325 | const glossaryBackground = document.getElementsByClassName("glossary")[0]; 326 | const glossaryCloseButton = document.getElementsByClassName("glossary-button")[0]; 327 | 328 | glossaryBackground.style.display = "flex"; 329 | glossaryCloseButton.onclick = function () { 330 | glossaryBackground.style.display = ""; 331 | }; 332 | 333 | break; 334 | case "information": 335 | const informationBackground = document.getElementsByClassName("information")[0]; 336 | const informationCloseButton = document.getElementsByClassName("information-button")[0]; 337 | 338 | informationBackground.style.display = "flex"; 339 | informationCloseButton.onclick = function () { 340 | informationBackground.style.display = ""; 341 | }; 342 | 343 | break; 344 | default: 345 | break; 346 | } 347 | } 348 | 349 | window.addEventListener("DOMContentLoaded", () => { 350 | window.addEventListener('resize', resize); 351 | 352 | function resize() { 353 | if (document.getElementsByTagName("svg").length === 1) { 354 | let svgWidth = document.getElementsByTagName("svg")[0].clientWidth; 355 | let svgHeight = document.getElementsByTagName("svg")[0].clientHeight; 356 | 357 | document.getElementById("graph-container").style.height = `${svgHeight}px`; 358 | document.getElementById("graph-container").style.width = `${svgWidth}px`; 359 | } 360 | } 361 | 362 | makeSeasonDropdown(); 363 | makeStatDropdown(); 364 | checkGraphGlow(); 365 | 366 | const playerInputEl = document.getElementById("search-players-input"); 367 | const glossaryButton = document.getElementById("glossary-button"); 368 | const informationButton = document.getElementById("information-button"); 369 | 370 | // every time the user changes the input field, call handlePlayerInput 371 | playerInputEl.oninput = handlePlayerInput; 372 | 373 | // if user clicks outside of the dropdown or input field, hide the dropdown 374 | // if it is currently on the page 375 | document.onclick = function (e) { 376 | const playerDropdown = document.getElementById("player-dropdown"); 377 | 378 | if (e.target.id !== "search-players-input" && e.target.className !== "player-item") { 379 | if (playerDropdown) { 380 | playerDropdown.style.display = "none"; 381 | } 382 | } 383 | }; 384 | 385 | // if user clicks on the input field, show the dropdown if it is currently 386 | // hidden 387 | // allows us to not send out another API call since input hasn't changed 388 | playerInputEl.onclick = function (e) { 389 | const playerDropdown = document.getElementById("player-dropdown"); 390 | 391 | if (playerDropdown) { 392 | playerDropdown.style.display = ""; 393 | } 394 | }; 395 | 396 | glossaryButton.onclick = () => makeModal("glossary"); 397 | informationButton.onclick = () => makeModal("information"); 398 | 399 | debouncedSearch = debounce(debouncedSearch, 400); 400 | 401 | makeGraph(data, false); 402 | }); --------------------------------------------------------------------------------