├── .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 |
29 |
30 |
31 |
32 | Information
33 |
34 |
35 | Glossary
36 |
37 |
38 |
39 |
40 |
41 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
93 |
94 |
95 |
96 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
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 = "Please select a season ";
44 |
45 | for (let year = startYear; year < end; year++) {
46 | options += `` + year + " ";
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 = "Please select a stat ";
61 |
62 | stats.forEach(stat => {
63 | let key = Object.keys(stat)[0];
64 | let val = Object.values(stat)[0];
65 |
66 | options += `` + val + " ";
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 | });
--------------------------------------------------------------------------------