├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _redirects ├── html ├── australian-rules.html ├── basketball-nba.html ├── basketball-ncaa.html ├── basketball-wnba.html ├── cfl-pre-2022.html ├── cfl.html ├── field-hockey.html ├── fistball.html ├── floorball.html ├── football-ncaa.html ├── football-nfl.html ├── gaa.html ├── handball-net.html ├── handball.html ├── ice-hockey-iihf.html ├── ice-hockey-net-nhl.html ├── ice-hockey.html ├── index.html ├── indoor-lacrosse.html ├── korfball.html ├── mens-lacrosse-net.html ├── mens-lacrosse.html ├── netball-ssn.html ├── rugby-union.html ├── soccer-ifab-m.html ├── soccer-ifab-yd.html ├── soccer-meerse.html ├── soccer-mls.html ├── soccer-ncaa.html ├── soccer-net-ifab.html ├── soccer-net-ncaa.html ├── soccer-premier-league.html ├── table-tennis.html ├── tennis.html ├── volleyball.html ├── womens-lacrosse-net.html └── womens-lacrosse.html ├── index.css ├── js ├── components │ └── upload-download.js ├── config-appearance.js ├── csv.js ├── custom-setups │ ├── card-setup.js │ ├── config-setup.js │ ├── min-max.js │ └── playing-area-setup.js ├── details │ ├── config-details.js │ ├── details-functions.js │ ├── details-panel.js │ ├── modal │ │ ├── details-modal.js │ │ ├── dropdown-page.js │ │ ├── json.js │ │ ├── main-page.js │ │ ├── radio-buttons-page.js │ │ ├── text-field-page.js │ │ ├── time-widget-page.js │ │ └── widget-type-page.js │ └── widgets │ │ ├── widgets-base.js │ │ └── widgets-special.js ├── playing-area.js ├── shots │ ├── delete-all-modal.js │ ├── dot.js │ ├── legend.js │ └── shot.js ├── table │ ├── filter.js │ ├── row.js │ ├── table-functions.js │ └── table.js └── toggles.js ├── preprocessing ├── analytics.html ├── banner.html ├── base.html ├── card.html ├── gulpfile.js ├── index-base.html ├── index.scss └── package.json ├── resources ├── australian-rules.svg ├── basketball-nba.svg ├── basketball-ncaa.svg ├── basketball-wnba.svg ├── cfl-pre-2022.svg ├── cfl.svg ├── favicon.svg ├── field-hockey.svg ├── fistball.svg ├── floorball.svg ├── football-ncaa.svg ├── football-nfl.svg ├── gaa.svg ├── handball-net.svg ├── handball.svg ├── ice-hockey-iihf.svg ├── ice-hockey-net-nhl.svg ├── ice-hockey-screenshot.png ├── ice-hockey.svg ├── indoor-lacrosse.svg ├── korfball.svg ├── mens-lacrosse-net.svg ├── mens-lacrosse.svg ├── netball-ssn.svg ├── rugby-union.svg ├── soccer-ifab-m.svg ├── soccer-ifab-yd.svg ├── soccer-meerse.svg ├── soccer-ncaa.svg ├── soccer-net-ifab.svg ├── soccer-net-ncaa.svg ├── sport-select-screenshot.png ├── table-tennis.svg ├── tennis.svg ├── volleyball.svg ├── womens-lacrosse-net.svg └── womens-lacrosse.svg ├── setup.js └── supported-sports.json /.gitignore: -------------------------------------------------------------------------------- 1 | preprocessing/appearance.js 2 | *.DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # sass output 110 | *.css.map 111 | .sass-cache/ 112 | sass/ 113 | 114 | package-lock.json 115 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | # Redirects for Netlify 2 | / /html/index 200 3 | /* /html/:splat 200 4 | -------------------------------------------------------------------------------- /html/ice-hockey-net-nhl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Shot-Plotter 15 | 16 | 17 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 76 | 77 | 78 | 84 | 89 | 90 | 91 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 |

Shot-Plotter

103 |
104 |
105 | A graphical interface for tracking locational events in sports. 106 | Click on the playing area to log an event! 107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 139 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 | 167 | 171 | 172 | 177 | 178 | 189 | 190 | -------------------------------------------------------------------------------- /html/soccer-net-ifab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Shot-Plotter 15 | 16 | 17 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 76 | 77 | 78 | 84 | 89 | 90 | 91 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 |

Shot-Plotter

103 |
104 |
105 | A graphical interface for tracking locational events in sports. 106 | Click on the playing area to log an event! 107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 | 144 | 148 | 149 | 154 | 155 | 166 | 167 | -------------------------------------------------------------------------------- /html/soccer-net-ncaa.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Shot-Plotter 15 | 16 | 17 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 76 | 77 | 78 | 84 | 89 | 90 | 91 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 |

Shot-Plotter

103 |
104 |
105 | A graphical interface for tracking locational events in sports. 106 | Click on the playing area to log an event! 107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | 146 | 150 | 151 | 156 | 157 | 168 | 169 | -------------------------------------------------------------------------------- /html/table-tennis.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Shot-Plotter 15 | 16 | 17 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 76 | 77 | 78 | 84 | 89 | 90 | 91 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 |

Shot-Plotter

103 |
104 |
105 | A graphical interface for tracking locational events in sports. 106 | Click on the playing area to log an event! 107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 | 149 | 153 | 154 | 159 | 160 | 171 | 172 | -------------------------------------------------------------------------------- /js/components/upload-download.js: -------------------------------------------------------------------------------- 1 | function downloadArea(id, defaultFileName, onClick, extension) { 2 | let wrapper = d3 3 | .select(id) 4 | .append("div") 5 | .attr("class", "input-group"); 6 | 7 | // Download Button 8 | wrapper 9 | .append("button") 10 | .attr("class", "input-group-text grey-hover-bg") 11 | .attr("type", "button") 12 | .attr("id", "download") 13 | .text("Download") 14 | .on("click", e => onClick(e)); 15 | 16 | wrapper 17 | .append("input") 18 | .attr("type", "text") 19 | .attr("class", "form-control download-name") 20 | .attr("placeholder", defaultFileName) 21 | .attr("aria-label", "download file name") 22 | .attr("aria-described-by", "download file name"); 23 | 24 | // .csv tack-on 25 | wrapper 26 | .append("span") 27 | .attr("class", "input-group-text white-bg") 28 | .text(extension); 29 | } 30 | 31 | function uploadArea(id, uploadId, onChange, warning) { 32 | let wrapper = d3 33 | .select(id) 34 | .append("div") 35 | .attr("class", "input-group"); 36 | 37 | let upload = wrapper 38 | .append("label") 39 | .attr("for", uploadId) 40 | .attr("class", "upload-area") 41 | .on("mouseover", function() { 42 | d3.select(this) 43 | .select("#upload-label") 44 | .attr("class", "input-group-text hover"); 45 | }) 46 | .on("mouseout", function() { 47 | d3.select(this) 48 | .select("#upload-label") 49 | .attr("class", "input-group-text"); 50 | }); 51 | 52 | upload 53 | .append("div") 54 | .attr("class", "input-group-text grey-btn") 55 | .attr("id", "upload-label") 56 | .text("Upload"); 57 | upload 58 | .append("div") 59 | .attr("class", "upload-name-box") 60 | .append("div") 61 | .attr("class", "upload-name-text") 62 | .text("No file chosen."); 63 | 64 | wrapper 65 | .append("input") 66 | .attr("type", "file") 67 | .attr("class", "form-control upload") 68 | .attr("id", uploadId) 69 | .attr("hidden", true) 70 | .on("change", onChange); 71 | wrapper 72 | .append("div") 73 | .attr("class", "invalid-tooltip") 74 | .text(warning); 75 | } 76 | 77 | export { downloadArea, uploadArea }; 78 | -------------------------------------------------------------------------------- /js/config-appearance.js: -------------------------------------------------------------------------------- 1 | const cfgAppearance = { 2 | // team colors 3 | blueTeam: "rgba(53, 171, 169, 0.7)", 4 | orangeTeam: "rgba(234, 142, 72, 0.7)", 5 | greyTeam: "rgba(170, 170, 170, 0.7)", 6 | blueTeamSolid: "rgba(53, 171, 169, 1)", 7 | orangeTeamSolid: "rgba(234, 142, 72, 1)", 8 | greyTeamSolid: "rgba(170, 170, 170, 1)", 9 | // transition durations 10 | newRowDuration: 650, 11 | selectDuration: 150, 12 | deleteDuration: 150, 13 | newDotDuration: 100, 14 | // how much larger a dot becomes when selected 15 | selectedMultiplier: 1.5, 16 | legendR: 9, 17 | }; 18 | 19 | export { cfgAppearance }; 20 | -------------------------------------------------------------------------------- /js/csv.js: -------------------------------------------------------------------------------- 1 | import { 2 | getDetails, 3 | getDetailTitle, 4 | existsDetail, 5 | getCurrentShotTypes, 6 | getTypeIndex, 7 | saveCurrentSetup, 8 | } from "./details/details-functions.js"; 9 | import { 10 | createFilterRow, 11 | select2Filter, 12 | existFilters, 13 | } from "./table/filter.js"; 14 | import { 15 | clearTable, 16 | getHeaderRow, 17 | getFilteredRows, 18 | getRows, 19 | } from "./table/table-functions.js"; 20 | import { updateTableFooter } from "./table/table.js"; 21 | import { createShotFromData } from "./shots/shot.js"; 22 | import { shotTypeLegend, teamLegend } from "./shots/legend.js"; 23 | import { downloadArea, uploadArea } from "./components/upload-download.js"; 24 | import { cfgSportA } from "../setup.js"; 25 | 26 | function setUpCSVDownloadUpload() { 27 | // Custom Filename 28 | const d = new Date(Date.now()); 29 | const defaultFileName = `${( 30 | d.getMonth() + 1 31 | ).toString()}.${d.getDate()}.${d.getFullYear()}-${d 32 | .getHours() 33 | .toString() 34 | .padStart(2, "0")}.${d.getMinutes().toString().padStart(2, "0")}`; 35 | downloadArea( 36 | "#csv-upload-download", 37 | defaultFileName, 38 | () => downloadCSV("#csv-upload-download"), 39 | ".csv" 40 | ); 41 | uploadArea( 42 | "#csv-upload-download", 43 | "csv-upload", 44 | (e) => uploadCSV("#csv-upload-download", "#csv-upload", e), 45 | "Only .csv files are allowed. The column headers in the .csv file must be identical to the column headers in the table, excluding #. Order matters." 46 | ); 47 | } 48 | 49 | export function toggleDownloadText() { 50 | const node = d3 51 | .select("#csv-upload-download") 52 | .select("button") 53 | .text(existFilters() ? "Download Filtered" : "Download"); 54 | } 55 | 56 | function downloadCSV(id) { 57 | // set up header row 58 | let csv = ""; 59 | let header = []; 60 | d3.select("#shot-table") 61 | .select("thead") 62 | .selectAll("th") 63 | .each(function () { 64 | header.push(d3.select(this).attr("data-id")); 65 | let text = d3.select(this).text(); 66 | if (text !== "" && text !== "#") { 67 | csv += text + ","; 68 | } 69 | }); 70 | csv = csv.slice(0, -1) + "\n"; 71 | const rows = getFilteredRows(); 72 | for (let row of rows) { 73 | for (let col of _.compact(header)) { 74 | if (col !== "shot-number") { 75 | csv += escape(row.rowData[col].toString()) + ","; 76 | } 77 | } 78 | // remove trailing comma 79 | csv = csv.slice(0, -1) + "\n"; 80 | } 81 | 82 | csv = csv.slice(0, -1); // remove trailing new line 83 | let fileName = d3.select(id).select(".download-name").property("value"); 84 | if (!fileName) { 85 | fileName = d3.select(id).select(".download-name").attr("placeholder"); 86 | } 87 | download(csv, fileName + ".csv", "text/csv"); 88 | } 89 | 90 | function escape(text) { 91 | return text.includes(",") ? '"' + text + '"' : text; 92 | } 93 | 94 | async function uploadCSV(id, uploadId, e) { 95 | if (/.csv$/i.exec(d3.select(uploadId).property("value"))) { 96 | const f = e.target.files[0]; 97 | if (f) { 98 | // change text and wipe value to allow for same file upload 99 | // while preserving name 100 | d3.select(id).select(".upload-name-text").text(f.name); 101 | d3.select(id).select(".upload").property("value", ""); 102 | 103 | // remove invalid class if necessary 104 | d3.select(uploadId).classed("is-invalid", false); 105 | let swapTeamColor = "blueTeam"; 106 | clearTable(); 107 | Papa.parse(f, { 108 | header: true, 109 | skipEmptyLines: true, 110 | step: function (row) { 111 | swapTeamColor = processCSV( 112 | uploadId, 113 | row.data, 114 | swapTeamColor 115 | ); 116 | }, 117 | }); 118 | updateTableFooter(); 119 | } 120 | } else { 121 | d3.select(uploadId).classed("is-invalid", true); 122 | } 123 | } 124 | 125 | function processCSV(uploadId, row, swapTeamColor) { 126 | // only process if current table header (minus shot) is Identical to the current header 127 | let tableHeader = []; 128 | d3.select("#shot-table") 129 | .select("thead") 130 | .selectAll("th") 131 | .each(function () { 132 | let text = d3.select(this).text(); 133 | if (text.length > 0 && text !== "#") { 134 | tableHeader.push(text); 135 | } 136 | }); 137 | const csvHeader = Object.keys(row); 138 | if (!_.isEqual(tableHeader, csvHeader)) { 139 | d3.select(uploadId).classed("is-invalid", true); 140 | return swapTeamColor; 141 | } 142 | 143 | // add any new shot type options 144 | if (existsDetail("#shot-type")) { 145 | const value = row[getDetailTitle("#shot-type")]; 146 | const typeOptions = getCurrentShotTypes().map((x) => x.value); 147 | if (typeOptions.indexOf(value) === -1) { 148 | d3.select("#shot-type-select").append("option").text(value); 149 | shotTypeLegend(); 150 | saveCurrentSetup(); 151 | createFilterRow(getDetails()); 152 | select2Filter(); 153 | } 154 | } 155 | 156 | let newSwapTeam = swapTeamColor; 157 | 158 | let teamColor; 159 | if (existsDetail("#team")) { 160 | const team = row[getDetailTitle("#team")]; 161 | // add any new team name 162 | if (!team) { 163 | teamColor = "blueTeam"; 164 | } else if (team === d3.select("#blue-team-name").property("value")) { 165 | teamColor = "blueTeam"; 166 | } else if (team === d3.select("#orange-team-name").property("value")) { 167 | teamColor = "orangeTeam"; 168 | } else { 169 | const swapTeamId = 170 | swapTeamColor === "blueTeam" 171 | ? "#blue-team-name" 172 | : "#orange-team-name"; 173 | d3.select(swapTeamId).property("value", team); 174 | teamLegend(); 175 | saveCurrentSetup(); 176 | createFilterRow(getDetails()); 177 | select2Filter(); 178 | 179 | teamColor = swapTeamColor; 180 | // alternate changing team names 181 | newSwapTeam = 182 | swapTeamColor === "blueTeam" ? "orangeTeam" : "blueTeam"; 183 | } 184 | } 185 | 186 | // add additional attributes to row 187 | let id = uuidv4(); 188 | 189 | let specialData = { 190 | typeIndex: getTypeIndex(row.Type), 191 | teamColor: teamColor, 192 | coords: [ 193 | parseFloat(row.X) + cfgSportA.width / 2, 194 | -1 * parseFloat(row.Y) + cfgSportA.height / 2, 195 | ], // undo coordinate adjustment 196 | player: row.Player, 197 | numberCol: _.findIndex(getHeaderRow(), { type: "shot-number" }) - 1, 198 | }; 199 | 200 | if (row.X2 && row.Y2) { 201 | specialData.coords2 = [ 202 | parseFloat(row.X2) + cfgSportA.width / 2, 203 | -1 * parseFloat(row.Y2) + cfgSportA.height / 2, 204 | ]; // undo coordinate adjustment 205 | } 206 | 207 | let headerIds = _.without( 208 | _.compact(getHeaderRow().map((x) => x.id)), 209 | "shot-number" 210 | ); 211 | let rowData = 212 | specialData.numberCol !== -2 213 | ? { "shot-number": getRows().length + 1 } 214 | : {}; 215 | _.forEach(_.zip(headerIds, Object.values(row)), function ([header, value]) { 216 | rowData[header] = value; 217 | }); 218 | 219 | createShotFromData(id, rowData, specialData); 220 | return newSwapTeam; 221 | } 222 | 223 | export { setUpCSVDownloadUpload }; 224 | -------------------------------------------------------------------------------- /js/custom-setups/card-setup.js: -------------------------------------------------------------------------------- 1 | import { minMaxes } from "./min-max.js"; 2 | 3 | export function customCardSetup(s) { 4 | customWidthHeightCardSetup(s.id, s.appearance.width, s.appearance.height); 5 | if (s.id === "ice-hockey-iihf") { 6 | customCornerRadiusSetup(s.id, s.appearance.cornerRadius); 7 | } 8 | } 9 | 10 | function customWidthHeightCardSetup(id, width, height) { 11 | const card = d3.select(`#${id}`).attr("href", undefined); 12 | const dim = card.select(".card-text").select(".dimensions"); 13 | const minMax = minMaxes[id]; 14 | dim.selectAll("*").remove(); 15 | dim.append("div").attr("class", "bold").text("Dimensions: "); 16 | const widthField = dim.append("div"); 17 | widthField.append("label").attr("for", `${id}-width`).text("Width:"); 18 | widthField 19 | .append("input") 20 | .attr("id", `${id}-width`) 21 | .attr("type", "number") 22 | .attr("name", `${id}-width`) 23 | .attr("min", minMax.minWidth) 24 | .attr("max", minMax.maxWidth) 25 | .attr("value", width) 26 | .attr("disabled", minMax.minWidth === minMax.maxWidth ? true : null); 27 | if (minMax.minWidth !== minMax.maxWidth) { 28 | widthField 29 | .append("span") 30 | .text(`(min: ${minMax.minWidth}, max: ${minMax.maxWidth})`); 31 | } 32 | 33 | const heightField = dim.append("div"); 34 | heightField.append("label").attr("for", `${id}-height`).text("Height:"); 35 | heightField 36 | .append("input") 37 | .attr("id", `${id}-height`) 38 | .attr("type", "number") 39 | .attr("name", `${id}-height`) 40 | .attr("min", minMax.minHeight) 41 | .attr("max", minMax.maxHeight) 42 | .attr("value", height) 43 | .attr("disabled", minMax.minHeight === minMax.maxHeight ? true : null); 44 | if (minMax.minHeight !== minMax.maxHeight) { 45 | heightField 46 | .append("span") 47 | .text(`(min: ${minMax.minHeight}, max: ${minMax.maxHeight})`); 48 | } 49 | 50 | card.select("button").on("click", function () { 51 | const width = d3.select(`#${id}-width`).property("value"); 52 | const height = d3.select(`#${id}-height`).property("value"); 53 | let params = new URLSearchParams({ 54 | width: width, 55 | height: height, 56 | }); 57 | window.location.href = `./${id}?${params.toString()}`; 58 | }); 59 | } 60 | 61 | function customCornerRadiusSetup(id, cornerRadius) { 62 | const minMax = minMaxes[id]; 63 | const card = d3.select(`#${id}`).attr("href", undefined); 64 | const dim = card.select(".card-text").select(".dimensions"); 65 | const cornerRadiusField = dim.append("div"); 66 | cornerRadiusField 67 | .append("label") 68 | .attr("for", `${id}-corner-radius`) 69 | .text("Corner Radius:"); 70 | cornerRadiusField 71 | .append("input") 72 | .attr("id", `${id}-corner-radius`) 73 | .attr("type", "number") 74 | .attr("name", `${id}-corner-radius`) 75 | .attr("min", minMax.minCornerRadius) 76 | .attr("max", minMax.maxCornerRadius) 77 | .attr("step", 0.1) 78 | .attr("value", cornerRadius); 79 | cornerRadiusField 80 | .append("span") 81 | .text( 82 | `(min: ${minMax.minCornerRadius}, max: ${minMax.maxCornerRadius})` 83 | ); 84 | 85 | card.select("button").on("click", function () { 86 | const width = d3.select(`#${id}-width`).property("value"); 87 | const height = d3.select(`#${id}-height`).property("value"); 88 | const cornerRadius = d3 89 | .select(`#${id}-corner-radius`) 90 | .property("value"); 91 | let params = new URLSearchParams({ 92 | width: width, 93 | height: height, 94 | cornerRadius: cornerRadius, 95 | }); 96 | window.location.href = `./${id}?${params.toString()}`; 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /js/custom-setups/config-setup.js: -------------------------------------------------------------------------------- 1 | export function customConfigSetup(config) { 2 | const urlParams = new URLSearchParams(window.location.search); 3 | var c = customWidthHeightSetup(urlParams, config); 4 | if (config.appearance.hasOwnProperty("cornerRadius")) { 5 | c.appearance.cornerRadius = 6 | parseFloat(urlParams.get("cornerRadius")) || 7 | config.appearance.cornerRadius; 8 | } 9 | return c; 10 | } 11 | 12 | function customWidthHeightSetup(urlParams, config) { 13 | const w = parseFloat(urlParams.get("width")) || config.appearance.width; 14 | const h = parseFloat(urlParams.get("height")) || config.appearance.height; 15 | config.appearance.width = w; 16 | config.appearance.height = h; 17 | config.goalCoords = [ 18 | [0, h / 2], 19 | [w, h / 2], 20 | ]; 21 | 22 | const ice_hockey_width = 200; 23 | const ice_hockey = { 24 | circleR: "2", 25 | polyR: "2.75", 26 | fontSize: "0.15", //rem 27 | strokeWidth: "0.5", //px 28 | heatMapScale: 1.25, 29 | }; 30 | 31 | const scaleFactor = parseFloat(ice_hockey_width) / Math.max(w, h); 32 | for (const key in ice_hockey) { 33 | if (key === "heatMapScale") { 34 | config.appearance[key] = parseFloat(ice_hockey[key]) * scaleFactor; 35 | } else { 36 | const val = parseFloat(ice_hockey[key]) / scaleFactor; 37 | const suffix = 38 | key === "fontSize" ? "rem" : key === "strokeWidth" ? "px" : ""; 39 | config.appearance[key] = `${val.toFixed(3)}${suffix}`; 40 | } 41 | } 42 | return config; 43 | } 44 | -------------------------------------------------------------------------------- /js/custom-setups/min-max.js: -------------------------------------------------------------------------------- 1 | export const minMaxes = { 2 | "soccer-ifab-yd": { 3 | minWidth: 100, 4 | maxWidth: 130, 5 | minHeight: 50, 6 | maxHeight: 100, 7 | }, 8 | "soccer-ifab-m": { 9 | minWidth: 90, 10 | maxWidth: 120, 11 | minHeight: 45, 12 | maxHeight: 90, 13 | }, 14 | "soccer-ncaa": { 15 | minWidth: 115, 16 | maxWidth: 120, 17 | minHeight: 70, 18 | maxHeight: 75, 19 | }, 20 | "indoor-lacrosse": { 21 | minWidth: 180, 22 | maxWidth: 200, 23 | minHeight: 85, 24 | maxHeight: 85, 25 | }, 26 | "ice-hockey-iihf": { 27 | minWidth: 60, 28 | maxWidth: 60, 29 | minHeight: 26, 30 | maxHeight: 30, 31 | minCornerRadius: 7, 32 | maxCornerRadius: 8.5, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /js/details/config-details.js: -------------------------------------------------------------------------------- 1 | const cfgDetails = { 2 | detailClass: "detail-module", 3 | defaultRowsPerPage: 10, 4 | defaultWidgetsPerRow: 2, 5 | }; 6 | const cfgOtherSetup = { 7 | rowsPerPage: 10, 8 | widgetsPerRow: 2, 9 | heatMapEnable: false, 10 | }; 11 | export { cfgDetails, cfgOtherSetup }; 12 | -------------------------------------------------------------------------------- /js/details/details-functions.js: -------------------------------------------------------------------------------- 1 | import { dataStorage } from "../../setup.js"; 2 | 3 | function getDetails() { 4 | return getCustomSetup().details; 5 | } 6 | 7 | function setDetails(detailsList) { 8 | setCustomSetup({ ...getCustomSetup(), details: detailsList }); 9 | } 10 | 11 | export function getCustomSetup() { 12 | return dataStorage.get("customSetup"); 13 | } 14 | 15 | export function setCustomSetup(setup) { 16 | dataStorage.set("customSetup", setup); 17 | } 18 | 19 | function existsDetail(id) { 20 | return !d3.select(id).empty(); 21 | } 22 | 23 | export function getDetailTitle(id) { 24 | return _.find(getDetails(), ["id", _.trim(id, "#")]).title; 25 | } 26 | 27 | export function setCustomSetupUploadFlag(bool) { 28 | dataStorage.set("customSetupUploadFlag", bool); 29 | } 30 | 31 | export function resetCustomSetupUploadFlag() { 32 | let value = dataStorage.get("customSetupUploadFlag"); 33 | setCustomSetupUploadFlag(false); 34 | return value; 35 | } 36 | 37 | function getCurrentShotTypes() { 38 | let options = []; 39 | if (existsDetail("#shot-type")) { 40 | d3.select("#shot-type-select") 41 | .selectAll("option") 42 | .each(function () { 43 | let obj = { 44 | value: d3.select(this).property("value"), 45 | }; 46 | if ( 47 | d3.select("#shot-type-select").property("value") === 48 | obj.value 49 | ) { 50 | obj["selected"] = true; 51 | } 52 | 53 | options.push(obj); 54 | }); 55 | } 56 | return options; 57 | } 58 | 59 | function getTypeIndex(type) { 60 | if (!existsDetail("#shot-type")) { 61 | return 0; 62 | } 63 | return type ? _.findIndex(getCurrentShotTypes(), { value: type }) : 0; 64 | } 65 | 66 | function changePage(currentPageId, newPageId) { 67 | d3.select(currentPageId).attr("hidden", true); 68 | d3.select(newPageId).attr("hidden", null); 69 | } 70 | 71 | function createId(title) { 72 | // lowercase and replace all whitespace 73 | // if starts with a number, insert a dummy letter "a" at start 74 | let id = title 75 | .toLowerCase() 76 | .replace(/\s/g, "-") // lowercase and replace all whitespace 77 | .replace(/^\d/, (d) => "a" + d); 78 | 79 | while ( 80 | _.findIndex(getDetails(), { id: id }) !== -1 || 81 | id === "x2" || 82 | id === "y2" 83 | ) { 84 | id += "0"; 85 | } 86 | return id; 87 | } 88 | 89 | function saveCurrentSetup() { 90 | // based on select2, reorder and tag with hidden 91 | const details = getDetails("details"); 92 | let newDetails = []; 93 | d3.select("#reorder-columns") 94 | .selectAll("td") 95 | .each(function () { 96 | let detail = _.find(details, { 97 | id: d3.select(this).attr("data-id"), 98 | }); 99 | if ( 100 | d3.select(this).select("i").size() !== 0 && 101 | d3.select(this).select("i").attr("class") === 102 | "bi bi-eye-slash-fill" 103 | ) { 104 | detail["hidden"] = true; 105 | } else { 106 | detail["hidden"] = null; 107 | } 108 | // custom saves for each 109 | if (!detail.hidden && detail.id) { 110 | const d = d3.select("#details").select("#" + detail.id); 111 | if (!d.empty()) { 112 | switch (detail.type) { 113 | case "team": 114 | // save teams 115 | detail.blueTeamName = d 116 | .select("#blue-team-name") 117 | .property("value"); 118 | detail.orangeTeamName = d 119 | .select("#orange-team-name") 120 | .property("value"); 121 | detail.checked = d3 122 | .select("input[name='team-bool']:checked") 123 | .property("id"); 124 | break; 125 | 126 | case "player": 127 | case "text-field": 128 | // save current entry 129 | detail["defaultValue"] = d 130 | .select("input") 131 | .property("value"); 132 | break; 133 | case "shot-type": 134 | detail.options = getCurrentShotTypes(); 135 | break; 136 | case "dropdown": 137 | // save currently selected option 138 | let selectedValue = d 139 | .select("select") 140 | .property("value"); 141 | detail.options = detail.options.map(function (o) { 142 | let option = { value: o.value }; 143 | if (o.value === selectedValue) { 144 | option.selected = true; 145 | } 146 | return option; 147 | }); 148 | break; 149 | case "radio": 150 | // save current selection 151 | let checkedValue = d 152 | .select(`input[name='${detail.id}']:checked`) 153 | .property("value"); 154 | detail.options = detail.options.map(function (o) { 155 | let option = { value: o.value }; 156 | if (o.value === checkedValue) { 157 | option.checked = true; 158 | } 159 | return option; 160 | }); 161 | break; 162 | case "time": 163 | // save current time 164 | detail["defaultTime"] = d 165 | .select("input") 166 | .property("value"); 167 | break; 168 | } 169 | } 170 | } 171 | newDetails.push(detail); 172 | }); 173 | 174 | const customSetup = { 175 | details: newDetails, 176 | rowsPerPage: d3.select("#page-size-field").property("value"), 177 | widgetsPerRow: d3.select("#widgets-per-row-dropdown").property("value"), 178 | heatMapEnable: d3.select("#heat-map-enable").property("checked"), 179 | twoPointEnable: d3.select("#two-point-enable").property("checked"), 180 | }; 181 | setCustomSetup(customSetup); 182 | } 183 | 184 | export { 185 | getDetails, 186 | setDetails, 187 | existsDetail, 188 | getCurrentShotTypes, 189 | getTypeIndex, 190 | changePage, 191 | createId, 192 | saveCurrentSetup, 193 | }; 194 | -------------------------------------------------------------------------------- /js/details/details-panel.js: -------------------------------------------------------------------------------- 1 | import { setUpDetailsModal } from "./modal/details-modal.js"; 2 | import { 3 | createRadioButtons, 4 | createTextField, 5 | createDropdown, 6 | createTimeWidget, 7 | } from "./widgets/widgets-base.js"; 8 | import { 9 | createTooltip, 10 | teamRadioButtons, 11 | select2Dropdown, 12 | } from "./widgets/widgets-special.js"; 13 | import { 14 | setDetails, 15 | getDetails, 16 | getCurrentShotTypes, 17 | getCustomSetup, 18 | setCustomSetup, 19 | } from "./details-functions.js"; 20 | import { getDefaultSetup } from "../../setup.js"; 21 | import { getNumRows } from "../table/table-functions.js"; 22 | 23 | function setUpDetailsPanel(id = "#details") { 24 | if (!getCustomSetup()) { 25 | setCustomSetup(getDefaultSetup()); 26 | } 27 | 28 | createDetailsPanel(id); 29 | 30 | d3.select(id).on("mouseleave", (e) => { 31 | d3.select("#customize-btn").classed("is-invalid", false); 32 | }); 33 | 34 | setUpDetailsModal("#details-modal"); 35 | } 36 | 37 | function createDetailsPanel(id = "#details") { 38 | const { details, widgetsPerRow } = getCustomSetup(); 39 | 40 | const visibleDetails = _.filter(details, (x) => !(x.hidden || x.noWidget)); 41 | // clear existing details 42 | d3.select(id).selectAll("*").remove(); 43 | 44 | for (let [i, data] of visibleDetails.entries()) { 45 | let rowId = "#row" + (Math.floor(i / widgetsPerRow) + 1); 46 | 47 | if (i % widgetsPerRow == 0) { 48 | if (Math.floor(i / widgetsPerRow) > 0) { 49 | // need to add hr after row that isn't first row 50 | d3.select(id).append("hr"); 51 | } 52 | // need to create new row 53 | d3.select(id) 54 | .append("div") 55 | .attr("class", "detail-row") 56 | .attr("id", rowId.slice(1)); 57 | } else { 58 | // need to add dividing line 59 | d3.select(rowId).append("div").attr("class", "vr"); 60 | } 61 | 62 | switch (data.type) { 63 | case "team": 64 | teamRadioButtons(rowId, data); 65 | break; 66 | case "player": 67 | createTextField(rowId, data); 68 | createTooltip({ 69 | id: rowId, 70 | title: data.title, 71 | text: "Player will appear on dot if 2 or less characters long.", 72 | }); 73 | break; 74 | case "shot-type": 75 | createDropdown(rowId, data); 76 | createTooltip({ 77 | id: rowId, 78 | title: data.title, 79 | text: "To add new options, type into the dropdown, then select the new option or press Enter.", 80 | }); 81 | $(".select2").select2({ 82 | tags: true, 83 | }); 84 | break; 85 | case "radio": 86 | createRadioButtons(rowId, data); 87 | break; 88 | case "text-field": 89 | createTextField(rowId, data); 90 | break; 91 | case "dropdown": 92 | createDropdown(rowId, data); 93 | break; 94 | case "time": 95 | createTimeWidget(rowId, data); 96 | break; 97 | } 98 | } 99 | select2Dropdown(); 100 | d3.select(id).append("hr"); 101 | customizeButton(id); 102 | } 103 | 104 | function customizeButton(id) { 105 | let d = d3 106 | .select(id) 107 | .append("div") 108 | .attr("class", "center position-relative"); 109 | d.append("button") 110 | .attr("class", "form-control white-btn") 111 | .attr("id", "customize-btn") 112 | .text("Customize Setup") 113 | .on("click", (e) => { 114 | if (getNumRows() === 0) { 115 | // update details storage with shot options b/c this 116 | // was the most convenient place 117 | const options = getCurrentShotTypes(); 118 | let details = getDetails(); 119 | const typeIndex = _.findIndex(details, { id: "shot-type" }); 120 | if (typeIndex !== -1) { 121 | details[typeIndex]["options"] = options; 122 | setDetails(details); 123 | } 124 | 125 | // make sure main page is showing 126 | let m = d3.select("#details-modal").select(".modal-content"); 127 | m.selectAll(".modal-page").attr("hidden", true); 128 | m.select(".modal-header").attr("hidden", null); 129 | m.select("#main-page").attr("hidden", null); 130 | 131 | new bootstrap.Modal(document.getElementById("details-modal"), { 132 | backdrop: "static", 133 | keyboard: false, 134 | }).show(); 135 | } else { 136 | d3.select("#customize-btn").classed("is-invalid", true); 137 | } 138 | }); 139 | d.append("div") 140 | .attr("class", "invalid-tooltip") 141 | .text("Details can only be customized when no shots are recorded."); 142 | } 143 | 144 | export { setUpDetailsPanel, createDetailsPanel }; 145 | -------------------------------------------------------------------------------- /js/details/modal/details-modal.js: -------------------------------------------------------------------------------- 1 | import { createMainPage } from "./main-page.js"; 2 | import { createWidgetTypePage } from "./widget-type-page.js"; 3 | import { createTextFieldPage } from "./text-field-page.js"; 4 | import { createRadioButtonsPage } from "./radio-buttons-page.js"; 5 | import { createDropdownPage } from "./dropdown-page.js"; 6 | import { createTimeWidgetPage } from "./time-widget-page.js"; 7 | 8 | function setUpDetailsModal(id) { 9 | // modal 10 | let m = d3 11 | .select(id) 12 | .attr("class", "modal fade") 13 | .attr("data-bs-backdrop", "static") 14 | .attr("aria-hidden", true) 15 | .attr("aria-labelledby", "customize-details") 16 | .append("div") 17 | .attr("class", "modal-dialog modal-lg") 18 | .append("div") 19 | .attr("class", "modal-content"); 20 | // header 21 | let h = m.append("div").attr("class", "modal-header"); 22 | h.append("h5") 23 | .attr("class", "modal-title") 24 | .text("Customize Setup"); 25 | h.append("button") 26 | .attr("type", "button") 27 | .attr("class", "btn-close") 28 | .attr("data-bs-dismiss", "modal") 29 | .attr("aria-label", "Close"); 30 | 31 | // pages 32 | 33 | const pages = [ 34 | { id: "main-page", create: createMainPage }, 35 | { id: "widget-type-page", create: createWidgetTypePage }, 36 | { 37 | id: "radio-buttons-page", 38 | create: () => createRadioButtonsPage("#radio-buttons-page"), 39 | }, 40 | { 41 | id: "text-field-page", 42 | create: () => createTextFieldPage("#text-field-page"), 43 | }, 44 | { 45 | id: "dropdown-page", 46 | create: () => createDropdownPage("#dropdown-page"), 47 | }, 48 | { 49 | id: "time-widget-page", 50 | create: () => createTimeWidgetPage("#time-widget-page"), 51 | }, 52 | ]; 53 | 54 | for (const page of pages) { 55 | m.append("div") 56 | .attr("id", page.id) 57 | .attr("class", "modal-page") 58 | .attr("hidden", true); 59 | page.create("#" + page.id); 60 | 61 | // don't need to unhide main page because 62 | // it does that when clicking customize 63 | } 64 | } 65 | 66 | export { setUpDetailsModal }; 67 | -------------------------------------------------------------------------------- /js/details/modal/dropdown-page.js: -------------------------------------------------------------------------------- 1 | import { 2 | changePage, 3 | getDetails, 4 | setDetails, 5 | createId, 6 | } from "../details-functions.js"; 7 | import { createDropdown } from "../widgets/widgets-base.js"; 8 | import { createReorderColumns } from "./main-page.js"; 9 | 10 | function createDropdownPage(id, data) { 11 | d3.select(id).selectAll("*").remove(); 12 | 13 | let mb = d3 14 | .select(id) 15 | .append("div") 16 | .attr("id", "dropdown-page-mb") 17 | .attr("class", "modal-body"); 18 | 19 | // explanation text 20 | mb.append("h6").text("Create Dropdown Widget"); 21 | 22 | // example 23 | mb.append("div") 24 | .attr("id", "dropdown-page-example") 25 | .attr("class", "center example"); 26 | createDropdown( 27 | "#dropdown-page-example", 28 | data 29 | ? { ...data, id: "sample-dropdown" } 30 | : { 31 | id: "sample-dropdown", 32 | title: "Detail Name", 33 | options: [ 34 | { value: "Option 1", selected: true }, 35 | { value: "Option 2" }, 36 | ], 37 | } 38 | ); 39 | 40 | mb.append("div").text( 41 | "Enter the detail name. Then, input a list of options for the dropdown, each option on a new line. The first option will be the default selection." 42 | ); 43 | mb.append("hr"); 44 | // text field 45 | let form = mb 46 | .append("form") 47 | .attr("class", "need-validation") 48 | .attr("novalidate", "true"); 49 | let nameDiv = form 50 | .append("div") 51 | .attr("class", "form-group position-relative"); 52 | nameDiv 53 | .append("label") 54 | .attr("for", "dropdown-title") 55 | .attr("class", "form-label") 56 | .text("Detail Name"); 57 | nameDiv 58 | .append("input") 59 | .attr("type", "text") 60 | .attr("class", "form-control") 61 | .attr("id", "dropdown-title") 62 | .property("value", data ? data.title : ""); 63 | nameDiv 64 | .append("div") 65 | .attr("class", "invalid-tooltip") 66 | .text( 67 | "Detail names must be 1-16 characters long, and can only contain alphanumeric characters, dashes, underscores, and spaces." 68 | ); 69 | 70 | let optionsDiv = form 71 | .append("div") 72 | .attr("class", "form-group position-relative"); 73 | optionsDiv 74 | .append("label") 75 | .attr("for", "dropdown-field-default-text") 76 | .attr("class", "form-label") 77 | .text("Options"); 78 | optionsDiv 79 | .append("textarea") 80 | .attr("class", "form-control textarea") 81 | .attr("id", "dropdown-options") 82 | .attr("rows", "10") 83 | .text( 84 | data 85 | ? data.options.map((x) => x.value).join("\n") 86 | : "Option 1\nOption 2\n" 87 | ); 88 | optionsDiv 89 | .append("div") 90 | .attr("class", "invalid-tooltip") 91 | .text("Each option must be 1-50 characters long."); 92 | 93 | // footer 94 | let footer = d3.select(id).append("div").attr("class", "footer-row"); 95 | footer 96 | .append("button") 97 | .attr("type", "button") 98 | .attr("class", "grey-btn") 99 | .text("Back") 100 | .on( 101 | "click", 102 | data 103 | ? () => changePage(id, "#main-page") 104 | : () => changePage(id, "#widget-type-page") 105 | ); 106 | 107 | footer 108 | .append("button") 109 | .attr("type", "button") 110 | .attr("class", "grey-btn") 111 | .text("Create Dropdown") 112 | .on( 113 | "click", 114 | data ? () => createNewDropdown(data) : () => createNewDropdown() 115 | ); 116 | 117 | $("#sample-dropdown-select").select2({ 118 | dropdownParent: $("#sample-dropdown"), 119 | width: "100%", 120 | dropdownCssClass: "small-text", 121 | }); 122 | } 123 | 124 | function createNewDropdown(data) { 125 | let invalid = false; 126 | 127 | const title = d3.select("#dropdown-title").property("value"); 128 | if ( 129 | title.length < 1 || 130 | title.length > 16 || 131 | !/^[_a-zA-Z0-9- ]*$/.test(title) 132 | ) { 133 | d3.select("#dropdown-title").classed("is-invalid", true); 134 | invalid = true; 135 | } else { 136 | d3.select("#dropdown-title").classed("is-invalid", false); 137 | } 138 | 139 | const text = d3.select("#dropdown-options").property("value"); 140 | let optionValues = text.split("\n"); 141 | 142 | // drop empty value if it is last 143 | const last = optionValues.pop(); 144 | if (last !== "") { 145 | optionValues.push(last); 146 | } 147 | 148 | if (optionValues.some((value) => value < 1 || value > 50)) { 149 | d3.select("#dropdown-options").classed("is-invalid", true); 150 | invalid = true; 151 | } else { 152 | d3.select("#dropdown-options").classed("is-invalid", false); 153 | } 154 | if (invalid) { 155 | return; 156 | } 157 | 158 | let options = optionValues.map((value) => ({ 159 | value: value, 160 | })); 161 | options[0] = { ...options[0], selected: true }; 162 | 163 | let details = getDetails(); 164 | const newDetail = { 165 | type: "dropdown", 166 | title: title, 167 | id: createId(title), 168 | options: options, 169 | editable: true, 170 | }; 171 | if (data) { 172 | let i = _.findIndex(details, data); 173 | details.splice(i, 1, newDetail); 174 | } else { 175 | details.push(newDetail); 176 | } 177 | setDetails(details); 178 | createReorderColumns("#reorder"); 179 | 180 | changePage("#dropdown-page", "#main-page"); 181 | } 182 | 183 | export { createDropdownPage }; 184 | -------------------------------------------------------------------------------- /js/details/modal/json.js: -------------------------------------------------------------------------------- 1 | import { 2 | saveCurrentSetup, 3 | setDetails, 4 | setCustomSetupUploadFlag, 5 | getCustomSetup, 6 | setCustomSetup, 7 | } from "../details-functions.js"; 8 | import { downloadArea, uploadArea } from "../../components/upload-download.js"; 9 | import { createReorderColumns } from "./main-page.js"; 10 | 11 | function setUpJSONDownloadUpload(id) { 12 | // Custom Filename 13 | downloadArea(id, "custom-setup", () => downloadJSON(id), ".json"); 14 | uploadArea( 15 | id, 16 | "json-upload", 17 | (e) => uploadJSON(id, "#json-upload", e), 18 | "Only .json files are allowed." 19 | ); 20 | setCustomSetupUploadFlag(false); 21 | } 22 | 23 | function downloadJSON(id) { 24 | let fileName = d3.select(id).select(".download-name").property("value"); 25 | if (!fileName) { 26 | fileName = 27 | d3.select(id).select(".download-name").attr("placeholder") + 28 | ".json"; 29 | } 30 | saveCurrentSetup(); 31 | download( 32 | JSON.stringify(getCustomSetup(), null, 2), 33 | fileName, 34 | "application/json" 35 | ); 36 | } 37 | 38 | function uploadJSON(id, uploadId, e) { 39 | if (/.json$/i.exec(d3.select(uploadId).property("value"))) { 40 | const f = e.target.files[0]; 41 | if (f) { 42 | // change text and wipe value to allow for same file upload 43 | // while preserving name 44 | d3.select(id).select(".upload-name-text").text(f.name); 45 | d3.select(id).select(".upload").property("value", ""); 46 | // TODO: some actual input sanitization 47 | f.text().then(function (text) { 48 | let json = JSON.parse(text); 49 | let details; 50 | if (Array.isArray(json)) { 51 | // old version 52 | details = json; 53 | setDetails(details); 54 | } else { 55 | // new version 56 | details = json.details; 57 | d3.select("#page-size-field").property( 58 | "value", 59 | json.rowsPerPage ? json.rowsPerPage : 10 60 | ); 61 | d3.select("#heat-map-enable").property( 62 | "checked", 63 | json.heatMapEnable 64 | ? json.heatMapEnable 65 | : json.heatMapView 66 | ? json.heatMapView 67 | : false 68 | // TODO: do the disable stuff for two-point & heat map here too 69 | ); 70 | $("#widgets-per-row-dropdown").val( 71 | json.widgetsPerRow ? json.widgetsPerRow : "2" 72 | ); 73 | $("#widgets-per-row-dropdown").trigger("change"); 74 | setCustomSetup(json); 75 | } 76 | createReorderColumns("#reorder"); 77 | const detailToggles = [ 78 | { id: "x2", type: "x", selector: "#two-point-enable" }, 79 | { id: "distance-calc", selector: "#distance-calc" }, 80 | { id: "value-calc", selector: "#value-calc" }, 81 | { id: "xadj", type: "x", selector: "#adj-coords" }, 82 | ]; 83 | for (const detailToggle of detailToggles) { 84 | if (_.some(details, { id: detailToggle.id })) { 85 | d3.select(detailToggle.selector).property( 86 | "checked", 87 | true 88 | ); 89 | } else { 90 | d3.select(detailToggle.selector).property( 91 | "checked", 92 | false 93 | ); 94 | } 95 | } 96 | setCustomSetupUploadFlag(true); 97 | }); 98 | } 99 | } else { 100 | d3.select(id).select("#json-upload").classed("is-invalid", true); 101 | } 102 | } 103 | 104 | export { setUpJSONDownloadUpload }; 105 | -------------------------------------------------------------------------------- /js/details/modal/text-field-page.js: -------------------------------------------------------------------------------- 1 | import { 2 | changePage, 3 | getDetails, 4 | setDetails, 5 | createId, 6 | } from "../details-functions.js"; 7 | import { createTextField } from "../widgets/widgets-base.js"; 8 | import { createReorderColumns } from "./main-page.js"; 9 | 10 | function createTextFieldPage(id, data) { 11 | d3.select(id).selectAll("*").remove(); 12 | 13 | let mb = d3 14 | .select(id) 15 | .append("div") 16 | .attr("id", "text-field-page-mb") 17 | .attr("class", "modal-body"); 18 | 19 | // explanation text 20 | mb.append("h6").text("Create Text Field Widget"); 21 | 22 | // example 23 | mb.append("div") 24 | .attr("id", "text-field-page-example") 25 | .attr("class", "center example"); 26 | createTextField( 27 | "#text-field-page-example", 28 | data 29 | ? { ...data, id: "sample-text-field" } 30 | : { 31 | id: "sample-text-field", 32 | title: "Detail Name", 33 | defaultValue: "Default Text", 34 | } 35 | ); 36 | 37 | mb.append("div").text( 38 | "Enter the detail name and any default text for the text field." 39 | ); 40 | mb.append("hr"); 41 | // text field 42 | let form = mb 43 | .append("form") 44 | .attr("class", "need-validation") 45 | .attr("novalidate", "true"); 46 | let nameDiv = form 47 | .append("div") 48 | .attr("class", "form-group position-relative"); 49 | nameDiv 50 | .append("label") 51 | .attr("for", "text-field-title") 52 | .attr("class", "form-label") 53 | .text("Detail Name"); 54 | nameDiv 55 | .append("input") 56 | .attr("type", "text") 57 | .attr("class", "form-control") 58 | .attr("id", "text-field-title") 59 | .property("value", data ? data.title : ""); 60 | nameDiv 61 | .append("div") 62 | .attr("class", "invalid-tooltip") 63 | .text( 64 | "Detail names must be 1-16 characters long, and can only contain alphanumeric characters, dashes, underscores, and spaces." 65 | ); 66 | let defaultTextDiv = form 67 | .append("div") 68 | .attr("class", "form-group position-relative"); 69 | defaultTextDiv 70 | .append("label") 71 | .attr("for", "text-field-default-text") 72 | .attr("class", "form-label") 73 | .text("Default Text"); 74 | defaultTextDiv 75 | .append("input") 76 | .attr("type", "text") 77 | .attr("class", "form-control") 78 | .attr("id", "text-field-default-text") 79 | .property("value", data ? data.defaultValue : ""); 80 | defaultTextDiv 81 | .append("div") 82 | .attr("class", "invalid-tooltip") 83 | .text("Default text can be at most 32 characters long."); 84 | 85 | let checkbox = form.append("div").attr("class", "form-check"); 86 | checkbox 87 | .append("input") 88 | .attr("type", "checkbox") 89 | .attr("class", "form-check-input") 90 | .attr("id", "text-field-editable-checkbox"); 91 | checkbox 92 | .append("label") 93 | .attr("class", "form-check-label") 94 | .attr("for", "text-field-editable-checkbox") 95 | .text("Allow editing in data table."); 96 | // footer 97 | let footer = d3.select(id).append("div").attr("class", "footer-row"); 98 | footer 99 | .append("button") 100 | .attr("type", "button") 101 | .attr("class", "grey-btn") 102 | .text("Back") 103 | .on( 104 | "click", 105 | data 106 | ? () => changePage(id, "#main-page") 107 | : () => changePage(id, "#widget-type-page") 108 | ); 109 | 110 | footer 111 | .append("button") 112 | .attr("type", "button") 113 | .attr("class", "grey-btn") 114 | .text("Create Text Field") 115 | .on( 116 | "click", 117 | data ? () => createNewTextField(data) : () => createNewTextField() 118 | ); 119 | } 120 | 121 | function createNewTextField(data) { 122 | let invalid = false; 123 | 124 | const title = d3.select("#text-field-title").property("value"); 125 | if ( 126 | title.length < 1 || 127 | title.length > 16 || 128 | !/^[_a-zA-Z0-9- ]*$/.test(title) 129 | ) { 130 | d3.select("#text-field-title").classed("is-invalid", true); 131 | invalid = true; 132 | } else { 133 | d3.select("#text-field-title").classed("is-invalid", false); 134 | } 135 | 136 | const text = d3.select("#text-field-default-text").property("value"); 137 | if (text.length >= 32) { 138 | d3.select("#text-field-default-text").classed("is-invalid", true); 139 | invalid = true; 140 | } else { 141 | d3.select("#text-field-default-text").classed("is-invalid", false); 142 | } 143 | if (invalid) { 144 | return; 145 | } 146 | 147 | let details = getDetails(); 148 | const newDetail = { 149 | type: "text-field", 150 | title: title, 151 | id: createId(title), 152 | defaultValue: text, 153 | editable: true, 154 | dataTableEditable: d3 155 | .select("#text-field-editable-checkbox") 156 | .property("checked"), 157 | }; 158 | if (data) { 159 | let i = _.findIndex(details, data); 160 | details.splice(i, 1, newDetail); 161 | } else { 162 | details.push(newDetail); 163 | } 164 | setDetails(details); 165 | createReorderColumns("#reorder"); 166 | 167 | changePage("#text-field-page", "#main-page"); 168 | } 169 | 170 | export { createTextFieldPage }; 171 | -------------------------------------------------------------------------------- /js/details/modal/time-widget-page.js: -------------------------------------------------------------------------------- 1 | import { 2 | changePage, 3 | getDetails, 4 | setDetails, 5 | createId, 6 | } from "../details-functions.js"; 7 | import { createTimeWidget } from "../widgets/widgets-base.js"; 8 | import { createReorderColumns } from "./main-page.js"; 9 | 10 | function createTimeWidgetPage(id, data) { 11 | d3.select(id) 12 | .selectAll("*") 13 | .remove(); 14 | 15 | let mb = d3 16 | .select(id) 17 | .append("div") 18 | .attr("id", "time-widget-page-mb") 19 | .attr("class", "modal-body"); 20 | 21 | // explanation text 22 | mb.append("h6").text("Create Time Widget"); 23 | 24 | // example 25 | mb.append("div") 26 | .attr("id", "time-page-example") 27 | .attr("class", "center example"); 28 | createTimeWidget( 29 | "#time-page-example", 30 | data 31 | ? { ...data, id: "sample-time" } 32 | : { 33 | id: "sample-time", 34 | title: "Detail Name", 35 | defaultTime: "60:00", 36 | countdown: true, 37 | } 38 | ); 39 | 40 | mb.append("div").text( 41 | "Enter the detail name, whether the time widget should count up or count down, and what the starting time should be." 42 | ); 43 | mb.append("hr"); 44 | // text field 45 | let form = mb 46 | .append("form") 47 | .attr("class", "need-validation") 48 | .attr("novalidate", "true"); 49 | let nameDiv = form 50 | .append("div") 51 | .attr("class", "form-group position-relative"); 52 | nameDiv 53 | .append("label") 54 | .attr("for", "time-widget-title") 55 | .attr("class", "form-label") 56 | .text("Detail Name"); 57 | nameDiv 58 | .append("input") 59 | .attr("type", "text") 60 | .attr("class", "form-control") 61 | .attr("id", "time-widget-title") 62 | .property("value", data ? data.title : ""); 63 | nameDiv 64 | .append("div") 65 | .attr("class", "invalid-tooltip") 66 | .text( 67 | "Detail names must be 1-16 characters long, and can only contain alphanumeric characters, dashes, underscores, and spaces." 68 | ); 69 | 70 | let countdownDiv = form 71 | .append("div") 72 | .attr("class", "form-group position-relative"); 73 | countdownDiv 74 | .append("label") 75 | .attr("for", "text-field-title") 76 | .attr("class", "form-label") 77 | .text("Countdown or Count Up"); 78 | for (let option of [ 79 | { 80 | text: "Countdown (i.e. timer)", 81 | id: "countdown", 82 | checked: data ? data.countdown : true, 83 | }, 84 | { 85 | text: "Count Up (i.e. stopwatch)", 86 | id: "count-up", 87 | checked: data ? (!data.countdown ? true : null) : null, 88 | }, 89 | ]) { 90 | let optionDiv = countdownDiv.append("div").attr("class", "form-check"); 91 | optionDiv 92 | .append("input") 93 | .attr("class", "form-check-input") 94 | .attr("type", "radio") 95 | .attr("name", "countdown-countup") 96 | .attr("id", option.id) 97 | .attr("value", option.id) 98 | .attr("checked", option.checked); 99 | optionDiv 100 | .append("label") 101 | .attr("class", "form-check-label") 102 | .attr("for", option.id) 103 | .text(option.text); 104 | } 105 | 106 | let defaultTimeDiv = form 107 | .append("div") 108 | .attr("class", "form-group position-relative"); 109 | defaultTimeDiv 110 | .append("label") 111 | .attr("for", "time-widget-default-time") 112 | .attr("class", "form-label") 113 | .text("Starting Time"); 114 | defaultTimeDiv 115 | .append("input") 116 | .attr("type", "text") 117 | .attr("class", "form-control") 118 | .attr("id", "time-widget-default-time") 119 | .property("value", data ? data.defaultTime : ""); 120 | defaultTimeDiv 121 | .append("div") 122 | .attr("class", "invalid-tooltip") 123 | .text("Times must be in the form 'MM:SS' or 'M:SS'."); 124 | 125 | // footer 126 | let footer = d3 127 | .select(id) 128 | .append("div") 129 | .attr("class", "footer-row"); 130 | footer 131 | .append("button") 132 | .attr("type", "button") 133 | .attr("class", "grey-btn") 134 | .text("Back") 135 | .on( 136 | "click", 137 | data 138 | ? () => changePage(id, "#main-page") 139 | : () => changePage(id, "#widget-type-page") 140 | ); 141 | 142 | footer 143 | .append("button") 144 | .attr("type", "button") 145 | .attr("class", "grey-btn") 146 | .text("Create Time Widget") 147 | .on( 148 | "click", 149 | data ? () => createNewTimeWidget(data) : () => createNewTimeWidget() 150 | ); 151 | } 152 | 153 | function createNewTimeWidget(data) { 154 | let invalid = false; 155 | 156 | const title = d3.select("#time-widget-title").property("value"); 157 | if ( 158 | title.length < 1 || 159 | title.length > 16 || 160 | !/^[_a-zA-Z0-9- ]*$/.test(title) 161 | ) { 162 | d3.select("#time-widget-title").classed("is-invalid", true); 163 | invalid = true; 164 | } else { 165 | d3.select("#time-widget-title").classed("is-invalid", false); 166 | } 167 | const countdown = 168 | d3 169 | .select(`input[name="countdown-countup"]:checked`) 170 | .property("value") === "countdown" 171 | ? true 172 | : null; 173 | 174 | const defaultTime = d3 175 | .select("#time-widget-default-time") 176 | .property("value"); 177 | if (!/^\d{1,2}:\d\d$/.test(defaultTime)) { 178 | d3.select("#time-widget-default-time").classed("is-invalid", true); 179 | invalid = true; 180 | } else { 181 | d3.select("#time-widget-default-time").classed("is-invalid", false); 182 | } 183 | if (invalid) { 184 | return; 185 | } 186 | 187 | let details = getDetails(); 188 | const newDetail = { 189 | type: "time", 190 | title: title, 191 | id: createId(title), 192 | defaultTime: defaultTime, 193 | countdown: countdown, 194 | editable: true, 195 | }; 196 | if (data) { 197 | let i = _.findIndex(details, data); 198 | details.splice(i, 1, newDetail); 199 | } else { 200 | details.push(newDetail); 201 | } 202 | setDetails(details); 203 | createReorderColumns("#reorder"); 204 | 205 | changePage("#time-widget-page", "#main-page"); 206 | } 207 | 208 | export { createTimeWidgetPage }; 209 | -------------------------------------------------------------------------------- /js/details/widgets/widgets-base.js: -------------------------------------------------------------------------------- 1 | import { cfgDetails } from "../config-details.js"; 2 | 3 | function createRadioButtons(selectId, { id, title, options }) { 4 | d3.select(selectId) 5 | .append("div") 6 | .attr("class", cfgDetails.detailClass) 7 | .attr("id", id) 8 | .append("h3") 9 | .text(title) 10 | .attr("class", "center"); 11 | 12 | for (let option of options) { 13 | let div = d3 14 | .select("#" + id) 15 | .append("div") 16 | .attr("class", "form-check vertical"); 17 | 18 | div.append("input") 19 | .attr("class", "form-check-input") 20 | .attr("type", "radio") 21 | .attr("name", id) 22 | .attr("id", option.value) // sanitize, make sure no duplicate values 23 | .attr("value", option.value) 24 | .attr("checked", option.checked); 25 | div.append("label") 26 | .attr("class", "form-check-label") 27 | .attr("for", option.value) 28 | .text(option.value); 29 | } 30 | } 31 | 32 | function createTextField( 33 | selectId, 34 | { id, title, defaultValue, dataTableEditable } 35 | ) { 36 | let div = d3 37 | .select(selectId) 38 | .append("div") 39 | .attr("class", cfgDetails.detailClass + " " + "even-width") 40 | .attr("id", id); 41 | div.append("h3").text(title).attr("class", "center"); 42 | div.append("div") 43 | .attr("class", "form-group") 44 | .append("input") 45 | .attr("type", "text") 46 | .attr("class", "form-control") 47 | .attr("value", defaultValue); 48 | } 49 | 50 | function createDropdown(selectId, { id, title, options }) { 51 | let div = d3 52 | .select(selectId) 53 | .append("div") 54 | .attr("class", cfgDetails.detailClass + " " + "even-width") 55 | .attr("id", id); 56 | 57 | div.append("h3").text(title).attr("class", "center"); 58 | 59 | let select = div 60 | .append("div") 61 | .append("select") 62 | .attr("id", id + "-select") 63 | .attr("class", "select2"); 64 | for (let option of options) { 65 | select 66 | .append("option") 67 | .text(option.value) 68 | .attr("selected", option.selected); 69 | } 70 | } 71 | 72 | function createTimeWidget(selectId, { id, title, defaultTime, countdown }) { 73 | let div = d3 74 | .select(selectId) 75 | .append("div") 76 | .attr("class", cfgDetails.detailClass + " even-width") 77 | .attr("id", id); 78 | div.append("h3").text(title).attr("class", "center"); 79 | const timer = new Tock({ 80 | countdown: countdown, 81 | interval: 10, 82 | callback: () => { 83 | let t = timer.lap(); 84 | let min = d3 85 | .select("#" + id) 86 | .select("input") 87 | .property( 88 | "value", 89 | `${parseInt(t / 60000) 90 | .toString() 91 | .padStart(1, "0")}:${parseInt((t % 60000) / 1000) 92 | .toString() 93 | .padStart(2, "0")}` 94 | ); 95 | }, 96 | }); 97 | let text = div.append("div").attr("class", "time-widget position-relative"); 98 | text.append("input") 99 | .attr("type", "text") 100 | .attr("class", "form-control time-box") 101 | .attr("value", defaultTime); 102 | text.append("div") 103 | .attr("class", "invalid-tooltip") 104 | .text("Times must be in the form 'MM:SS' or 'M:SS'."); 105 | text.append("div") 106 | .attr("class", "white-btn time-btn") 107 | .on("click", function () { 108 | if ( 109 | d3.select(this).select("i").attr("class") === "bi bi-stop-fill" 110 | ) { 111 | timer.stop(); 112 | d3.select(this).select("i").remove(); 113 | d3.select(this).append("i").attr("class", "bi bi-play-fill"); 114 | d3.select("#" + id) 115 | .select("input") 116 | .attr("disabled", null); 117 | } else { 118 | let time = d3 119 | .select("#" + id) 120 | .select("input") 121 | .property("value"); 122 | if (/^\d{1,2}:\d\d$/.test(time)) { 123 | d3.select("#" + id) 124 | .select("input") 125 | .attr("disabled", true) 126 | .attr("class", "form-control time-box"); 127 | d3.select(this).select("i").remove(); 128 | d3.select(this) 129 | .append("i") 130 | .attr("class", "bi bi-stop-fill"); 131 | timer.start(time); 132 | } else { 133 | d3.select("#" + id) 134 | .select("input") 135 | .attr("class", "form-control time-box is-invalid"); 136 | } 137 | } 138 | }) 139 | .append("i") 140 | .attr("class", "bi bi-play-fill"); 141 | } 142 | 143 | export { 144 | createRadioButtons, 145 | createTextField, 146 | createDropdown, 147 | createTimeWidget, 148 | }; 149 | -------------------------------------------------------------------------------- /js/details/widgets/widgets-special.js: -------------------------------------------------------------------------------- 1 | import { cfgDetails } from "../config-details.js"; 2 | import { shotTypeLegend, teamLegend } from "../../shots/legend.js"; 3 | import { 4 | updateDropdownFilter, 5 | createFilterRow, 6 | select2Filter, 7 | } from "../../table/filter.js"; 8 | import { saveCurrentSetup, getDetails } from "../details-functions.js"; 9 | 10 | function createTooltip({ id, title, text }) { 11 | // https://bl.ocks.org/d3noob/a22c42db65eb00d4e369 12 | let tooltip = d3 13 | .select("body") 14 | .append("div") 15 | .attr("class", "tooltip") 16 | .style("opacity", 0) 17 | .text(text); 18 | d3.select(id) 19 | .selectAll("h3") 20 | .each(function () { 21 | let h = d3.select(this); 22 | if (h.text() === title) { 23 | h.append("i") 24 | .attr("class", "bi bi-info-circle") 25 | .on("mouseover", function (e) { 26 | tooltip 27 | .transition() 28 | .duration(200) 29 | .style("opacity", 0.9) 30 | .style("left", e.pageX + 10 + "px") 31 | .style("top", e.pageY - 28 + "px"); 32 | }) 33 | .on("mouseout", function () { 34 | tooltip 35 | .transition() 36 | .duration(200) 37 | .style("opacity", 0.0); 38 | }); 39 | } 40 | }); 41 | } 42 | 43 | function teamRadioButtons(id, data) { 44 | d3.select(id) 45 | .append("div") 46 | .attr("class", cfgDetails.detailClass + " " + data.class) 47 | .attr("id", data.id) 48 | .append("h3") 49 | .text(data.title) 50 | .attr("class", "center"); 51 | 52 | let wrapper = d3 53 | .select("." + data.class) 54 | .append("div") 55 | .attr("class", "form-group"); 56 | let blueDiv = wrapper.append("div").attr("class", "form-check"); 57 | 58 | const changeFunction = () => { 59 | teamLegend(); 60 | saveCurrentSetup(); 61 | createFilterRow(getDetails()); 62 | select2Filter(); 63 | }; 64 | 65 | blueDiv 66 | .append("input") 67 | .attr("class", "form-check-input") 68 | .attr("type", "radio") 69 | .attr("name", "team-bool") 70 | .attr("id", "blue-team-select") 71 | .attr("value", "blueTeam"); 72 | blueDiv 73 | .append("input") 74 | .attr("type", "text") 75 | .attr("id", "blue-team-name") 76 | .attr("value", data.blueTeamName) 77 | .on("change", changeFunction); 78 | 79 | let orangeDiv = wrapper.append("div").attr("class", "form-check"); 80 | orangeDiv 81 | .append("input") 82 | .attr("class", "form-check-input") 83 | .attr("type", "radio") 84 | .attr("name", "team-bool") 85 | .attr("id", "orange-team-select") 86 | .attr("value", "orangeTeam"); 87 | orangeDiv 88 | .append("input") 89 | .attr("type", "text") 90 | .attr("id", "orange-team-name") 91 | .attr("value", data.orangeTeamName) 92 | .on("change", changeFunction); 93 | 94 | wrapper.select("#" + data.checked).attr("checked", true); 95 | } 96 | 97 | function select2Dropdown() { 98 | $(".select2").select2({}); 99 | 100 | select2Filter(); 101 | 102 | $("#sample-dropdown-select").select2({ 103 | dropdownParent: $("#sample-dropdown"), 104 | width: "100%", 105 | dropdownCssClass: "small-text", 106 | }); 107 | 108 | $("#shot-type-select") 109 | .select2({ 110 | tags: true, 111 | }) 112 | .on("change", function (e) { 113 | // update legend 114 | shotTypeLegend(); 115 | 116 | saveCurrentSetup(); 117 | createFilterRow(getDetails()); 118 | select2Filter(); 119 | 120 | // https://stackoverflow.com/a/54047075 121 | // do not delete new options 122 | $(this).find("option").removeAttr("data-select2-tag"); 123 | }); 124 | $("#example-select").select2({ 125 | dropdownParent: $(".cards"), 126 | width: "100%", 127 | dropdownCssClass: "small-text", 128 | }); 129 | 130 | $("#widgets-per-row-dropdown").select2({ 131 | dropdownParent: $("#main-page-mb"), 132 | width: "3em", 133 | dropdownCssClass: "small-text", 134 | }); 135 | } 136 | 137 | export { createTooltip, teamRadioButtons, select2Dropdown }; 138 | -------------------------------------------------------------------------------- /js/playing-area.js: -------------------------------------------------------------------------------- 1 | import { sport, cfgSportA, cfgSportCustomSetup } from "../setup.js"; 2 | import { customPlayingAreaSetup } from "./custom-setups/playing-area-setup.js"; 3 | 4 | function setUpPlayingArea() { 5 | if (cfgSportCustomSetup) { 6 | customPlayingAreaSetup(); 7 | } 8 | // dimensions of padding, window and playing area 9 | const padding = 20; 10 | const maxWidth = 11 | window.innerWidth >= 768 ? window.innerWidth * 0.7 : window.innerWidth; 12 | const paWidth = cfgSportA.width; 13 | const paHeight = cfgSportA.height; 14 | const scalar = Math.max(paWidth, paHeight); 15 | 16 | // floor resizing factor to the nearest 0.5 17 | let resize = ( 18 | (Math.floor((maxWidth - 2 * padding) / scalar) + 19 | Math.round((maxWidth - 2 * padding) / scalar)) / 20 | 2 21 | ).toFixed(1); 22 | 23 | if (paWidth / paHeight < 1.3) { 24 | const adjFactor = 0.6 + (Math.max(paWidth / paHeight, 1) - 1); 25 | resize = (Number(resize) * adjFactor).toFixed(1); 26 | } 27 | 28 | d3.select(`#${sport}-svg`) 29 | .attr("viewBox", undefined) 30 | .attr("width", resize * paWidth + padding) 31 | .attr("height", resize * paHeight + padding); 32 | 33 | let dots = d3 34 | .select("#playing-area") 35 | .select("#transformations") 36 | .attr("clip-path", "url(#clipBorder)") 37 | .attr( 38 | "transform", 39 | "translate(10,10) scale(" + resize + "," + resize + ")" 40 | ) 41 | .insert("svg:g", "#outside-perimeter") 42 | .attr("id", "dots"); 43 | 44 | for (const id of ["ghost", "normal", "selected"]) { 45 | dots.append("svg:g").attr("id", id).style("visibility", "hidden"); 46 | } 47 | 48 | d3.select("#transformations") 49 | .insert("g", "#outside-perimeter") 50 | .attr("id", "heat-map"); 51 | } 52 | 53 | export { setUpPlayingArea }; 54 | -------------------------------------------------------------------------------- /js/shots/delete-all-modal.js: -------------------------------------------------------------------------------- 1 | import { clearTable } from "../table/table-functions.js"; 2 | 3 | function setUpDeleteAllModal(id) { 4 | let m = d3 5 | .select(id) 6 | .attr("class", "modal fade") 7 | .attr("aria-hidden", true) 8 | .attr("aria-labelledby", "customize-options") 9 | .append("div") 10 | .attr("class", "modal-dialog") 11 | .append("div") 12 | .attr("class", "modal-content"); 13 | 14 | let h = m.append("div").attr("class", "modal-header"); 15 | h.append("h5") 16 | .attr("class", "modal-title") 17 | .text("Delete All Events"); 18 | h.append("button") 19 | .attr("type", "button") 20 | .attr("class", "btn-close") 21 | .attr("data-bs-dismiss", "modal") 22 | .attr("aria-label", "Close"); 23 | 24 | let mb = m 25 | .append("div") 26 | .attr("class", "modal-body") 27 | .text("Are you sure? This will delete all recorded events."); 28 | 29 | m.append("div") 30 | .attr("class", "modal-footer") 31 | .append("button") 32 | .attr("type", "button") 33 | .attr("class", "grey-btn") 34 | .text("Delete All") 35 | .on("click", () => { 36 | clearTable(); 37 | $(id).modal("hide"); // default js doesn't work for some reason 38 | }); 39 | } 40 | 41 | export { setUpDeleteAllModal }; 42 | -------------------------------------------------------------------------------- /js/shots/legend.js: -------------------------------------------------------------------------------- 1 | import { 2 | getDetails, 3 | existsDetail, 4 | getCurrentShotTypes, 5 | } from "../details/details-functions.js"; 6 | import { createDot } from "./dot.js"; 7 | import { cfgAppearance } from "../config-appearance.js"; 8 | 9 | function setUpLegend() { 10 | let div = d3.select("#legend").append("div"); 11 | div.append("div") 12 | .attr("class", "center") 13 | .append("svg") 14 | .attr("id", "shot-type-legend"); 15 | 16 | shotTypeLegend(); 17 | 18 | div.append("div") 19 | .attr("class", "center") 20 | .append("svg") 21 | .attr("id", "team-legend"); 22 | 23 | teamLegend(); 24 | } 25 | 26 | function shotTypeLegend(id = "#shot-type-legend") { 27 | let xOffset = 2 * cfgAppearance.legendR; 28 | let yOffset = 2 * cfgAppearance.legendR; 29 | const spacing = 2 * cfgAppearance.legendR; 30 | let svg = d3.select(id); 31 | 32 | // clear svg 33 | svg.selectAll("*").remove(); 34 | 35 | // if shot-type not in the details 36 | if (!existsDetail("#shot-type")) { 37 | svg.attr("width", 0).attr("height", 0); 38 | return; 39 | } 40 | 41 | const typeOptions = getCurrentShotTypes(); 42 | 43 | typeOptions.forEach(function(value, i) { 44 | let data = { 45 | teamId: true, 46 | player: "", 47 | typeIndex: i, 48 | coords: [xOffset, 0.625 * yOffset], 49 | legendBool: true, 50 | }; 51 | createDot(id, `legend-${Math.round(xOffset)}`, data); 52 | xOffset += spacing; 53 | xOffset += 54 | svg 55 | .append("text") 56 | .attr("x", xOffset) 57 | .attr("y", yOffset) 58 | .text(typeOptions[i].value) 59 | .node() 60 | .getComputedTextLength() + 61 | 2 * spacing; 62 | }); 63 | xOffset -= 2 * spacing; 64 | 65 | svg.attr("width", xOffset).attr("height", 2 * yOffset); 66 | } 67 | 68 | function teamLegend(id = "#team-legend") { 69 | let xOffset = 2 * cfgAppearance.legendR; 70 | let yOffset = 2 * cfgAppearance.legendR; 71 | const spacing = 2 * cfgAppearance.legendR; 72 | const svg = d3.select(id); 73 | 74 | // clear svg 75 | svg.selectAll("*").remove(); 76 | 77 | // do not do anything if team widget isn't present 78 | if (!existsDetail("#team")) { 79 | svg.attr("width", 0).attr("height", 0); 80 | return; 81 | } 82 | 83 | for (let [teamColor, text] of [ 84 | ["blueTeam", d3.select("#blue-team-name").property("value")], 85 | ["orangeTeam", d3.select("#orange-team-name").property("value")], 86 | ]) { 87 | svg.append("rect") 88 | .attr("x", xOffset - cfgAppearance.legendR) 89 | .attr("y", 0.25 * yOffset) 90 | .attr("width", 2 * cfgAppearance.legendR) 91 | .attr("height", 2 * cfgAppearance.legendR) 92 | .style("fill", cfgAppearance[teamColor]) 93 | .style("stroke-width", "0.05em") 94 | .style("stroke", cfgAppearance[teamColor + "Solid"]); 95 | xOffset += spacing; 96 | xOffset += 97 | svg 98 | .append("text") 99 | .attr("x", xOffset) 100 | .attr("y", yOffset) 101 | .text(text) 102 | .node() 103 | .getComputedTextLength() + 104 | 2 * spacing; 105 | } 106 | xOffset -= 2 * spacing; 107 | svg.attr("width", xOffset).attr("height", 2 * yOffset); 108 | } 109 | 110 | export { setUpLegend, shotTypeLegend, teamLegend }; 111 | -------------------------------------------------------------------------------- /js/table/table-functions.js: -------------------------------------------------------------------------------- 1 | import { updateTableFooter } from "./table.js"; 2 | import { dataStorage } from "../../setup.js"; 3 | 4 | function addRow(rowData) { 5 | dataStorage.push("rows", rowData); 6 | } 7 | 8 | function setRows(rows) { 9 | dataStorage.set("rows", rows); 10 | } 11 | 12 | function getRows() { 13 | return dataStorage.get("rows"); 14 | } 15 | 16 | function addFilteredRow(row) { 17 | dataStorage.push("filteredRows", row); 18 | } 19 | 20 | function setFilteredRows(rows) { 21 | dataStorage.set("filteredRows", rows); 22 | } 23 | 24 | function getFilteredRows() { 25 | return dataStorage.get("filteredRows"); 26 | } 27 | 28 | function getStartRow() { 29 | return dataStorage.get("startRow"); 30 | } 31 | 32 | function setStartRow(i) { 33 | dataStorage.set("startRow", i); 34 | } 35 | 36 | function getEndRow() { 37 | return dataStorage.get("endRow"); 38 | } 39 | 40 | function setEndRow(i) { 41 | dataStorage.set("endRow", i); 42 | } 43 | 44 | function getNumRows() { 45 | return dataStorage.get("numRows"); 46 | } 47 | 48 | function setNumRows(i) { 49 | dataStorage.set("numRows", i); 50 | } 51 | 52 | function getNumFilteredRows() { 53 | return dataStorage.get("numFilteredRows"); 54 | } 55 | 56 | function setNumFilteredRows(i) { 57 | dataStorage.set("numFilteredRows", i); 58 | } 59 | 60 | function getRowsPerPage() { 61 | return dataStorage.get("customSetup").rowsPerPage; 62 | } 63 | 64 | function getHeaderRow() { 65 | let l = []; 66 | d3.select("#shot-table") 67 | .select("thead") 68 | .selectAll("th") 69 | .each(function () { 70 | let dataId = d3.select(this).attr("data-id"); 71 | let dataType = d3.select(this).attr("data-type"); 72 | l.push({ id: dataId, type: dataType }); 73 | }); 74 | return l; 75 | } 76 | 77 | function clearTable() { 78 | setRows([]); 79 | setFilteredRows([]); 80 | setStartRow(0); 81 | setEndRow(0); 82 | setNumRows(0); 83 | setNumFilteredRows(0); 84 | updateTableFooter(); 85 | 86 | d3.select("#customize-btn").classed("uninteractable", false); 87 | 88 | d3.select("#shot-table-body").selectAll("tr").remove(); 89 | 90 | let dots = d3.select("#playing-area").select("#dots"); 91 | 92 | dots.select("#normal").selectAll("*").remove(); 93 | dots.select("#selected").selectAll("*").remove(); 94 | 95 | d3.select("#heat-map").selectAll("*").remove(); 96 | } 97 | 98 | export { 99 | addRow, 100 | setRows, 101 | getRows, 102 | addFilteredRow, 103 | setFilteredRows, 104 | getFilteredRows, 105 | getHeaderRow, 106 | clearTable, 107 | getStartRow, 108 | getEndRow, 109 | setStartRow, 110 | setEndRow, 111 | getNumRows, 112 | setNumRows, 113 | getRowsPerPage, 114 | getNumFilteredRows, 115 | setNumFilteredRows, 116 | }; 117 | -------------------------------------------------------------------------------- /preprocessing/analytics.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /preprocessing/banner.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /preprocessing/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Shot-Plotter 13 | 14 | 15 | 20 | 21 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | 48 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 72 | 73 | 74 | 75 | 81 | 82 | 83 | 89 | 94 | 95 | 96 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
107 |

Shot-Plotter

108 |
109 |
110 | A graphical interface for tracking locational events in sports. 111 | Click on the playing area to log an event! 112 |
113 |
114 | 115 |
116 |
117 |
118 |
119 |
120 |
121 | 122 | 123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 | 136 | 140 | 141 | 146 | 147 | 164 | 165 | -------------------------------------------------------------------------------- /preprocessing/card.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 |
7 |
8 | 9 |
10 |
11 |
12 | Dimensions: 14 | 15 | 16 |
17 |
18 | Units: 20 | 21 | 22 |
23 |
24 | Specifications: 26 | 27 | 28 |
29 |
30 |
31 | 37 |
38 | -------------------------------------------------------------------------------- /preprocessing/gulpfile.js: -------------------------------------------------------------------------------- 1 | const { parallel, series, src, dest } = require("gulp"); 2 | const gulpif = require("gulp-if"); 3 | const preprocess = require("gulp-preprocess"); 4 | const rename = require("gulp-rename"); 5 | const inject = require("gulp-inject"); 6 | const del = require("del"); 7 | const sports = require("../supported-sports.json").sports; 8 | 9 | const indexBanner = false; 10 | const banner = false; 11 | const analytics = true; 12 | 13 | function html(sport) { 14 | return src("./base.html") 15 | .pipe(preprocess({ context: { SPORT: sport } })) // set environment variables in-line 16 | .pipe( 17 | inject(src([`../resources/${sport}.svg`]), { 18 | starttag: "", 19 | transform: function (filePath, file) { 20 | // return file contents as string 21 | return file.contents.toString("utf8"); 22 | }, 23 | }) 24 | ) 25 | .pipe( 26 | gulpif( 27 | banner, 28 | inject(src(`./banner.html`), { 29 | starttag: "", 30 | transform: function (filePath, file) { 31 | // return file contents as string 32 | return file.contents.toString("utf8"); 33 | }, 34 | }) 35 | ) 36 | ) 37 | .pipe( 38 | gulpif( 39 | analytics, 40 | inject(src(`./analytics.html`), { 41 | starttag: "", 42 | transform: function (filePath, file) { 43 | // return file contents as string 44 | return file.contents.toString("utf8"); 45 | }, 46 | }) 47 | ) 48 | ) 49 | .pipe(rename(`${sport}.html`)) 50 | .pipe(dest("../html")); 51 | } 52 | 53 | function card(sport) { 54 | return src("./card.html") 55 | .pipe( 56 | preprocess({ 57 | context: { 58 | ID: sport.id, 59 | NAME: sport.name, 60 | DIMS: sport.dimensions, 61 | UNITS: sport.units, 62 | SPECS: sport.specifications, 63 | }, 64 | }) 65 | ) // set environment variables in-line 66 | .pipe( 67 | inject(src([`../resources/${sport.id}.svg`]), { 68 | starttag: "", 69 | transform: function (filePath, file) { 70 | // return file contents as string 71 | return file.contents.toString("utf8"); 72 | }, 73 | }) 74 | ) 75 | .pipe(rename(`${sport.id}-card.html`)) 76 | .pipe(dest("./card")); 77 | } 78 | 79 | function index() { 80 | const filePaths = sports 81 | .filter((s) => !s.private) 82 | .map((sport) => `./card/${sport.id}-card.html`); 83 | return src("./index-base.html") 84 | .pipe( 85 | inject(src(filePaths), { 86 | starttag: ``, 87 | transform: function (filePath, file) { 88 | // return file contents as string 89 | return file.contents.toString("utf8"); 90 | }, 91 | }) 92 | ) 93 | .pipe( 94 | gulpif( 95 | banner || indexBanner, 96 | inject(src(`./banner.html`), { 97 | starttag: "", 98 | transform: function (filePath, file) { 99 | // return file contents as string 100 | return file.contents.toString("utf8"); 101 | }, 102 | }) 103 | ) 104 | ) 105 | .pipe( 106 | gulpif( 107 | analytics, 108 | inject(src(`./analytics.html`), { 109 | starttag: "", 110 | transform: function (filePath, file) { 111 | // return file contents as string 112 | return file.contents.toString("utf8"); 113 | }, 114 | }) 115 | ) 116 | ) 117 | .pipe(rename(`index.html`)) 118 | .pipe(dest("../html")); 119 | } 120 | 121 | function clean() { 122 | return del(["./card"]); 123 | } 124 | 125 | exports.default = parallel( 126 | sports.map((sport) => () => html(sport.id)), 127 | series(parallel(sports.map((sport) => () => card(sport))), index, clean) 128 | ); 129 | -------------------------------------------------------------------------------- /preprocessing/index-base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Shot-Plotter 13 | 14 | 15 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 35 | 36 | 37 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 |

Shot-Plotter

53 |
54 |
55 | A graphical interface for tracking locational events in sports. 56 | Click on the sport you want! 57 |
58 |
59 | 60 |
61 | 62 | 63 |
64 |
65 | 66 | 82 | 83 | 87 | 98 | 99 | -------------------------------------------------------------------------------- /preprocessing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preprocessing", 3 | "version": "1.0.0", 4 | "main": "generate-html.js", 5 | "dependencies": { 6 | "del": "^6.0.0", 7 | "gulp": "^4.0.2", 8 | "gulp-if": "^3.0.0", 9 | "gulp-inject": "^5.0.5", 10 | "gulp-preprocess": "^4.0.2", 11 | "gulp-rename": "^2.0.0" 12 | }, 13 | "scripts": { 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "description": "" 19 | } 20 | -------------------------------------------------------------------------------- /resources/australian-rules.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 20 | 21 | 22 | 29 | 30 | 31 | 32 | 33 | 38 | 43 | 50 | 57 | 58 | 59 | 64 | 71 | 78 | 79 | 80 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /resources/basketball-nba.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 23 | 24 | 25 | 30 | 55 | 60 | 63 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 79 | 84 | 85 | 86 | 91 | 116 | 121 | 124 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 146 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /resources/basketball-ncaa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 21 | 22 | 23 | 24 | 45 | 48 | 49 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 67 | 70 | 71 | 72 | 73 | 94 | 97 | 98 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 121 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /resources/basketball-wnba.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 24 | 25 | 26 | 31 | 56 | 61 | 64 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 80 | 85 | 86 | 87 | 92 | 117 | 122 | 125 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 147 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /resources/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | S 4 | 5 | P 6 | 7 | -------------------------------------------------------------------------------- /resources/field-hockey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | 24 | 25 | 44 | 45 | 46 | 47 | 53 | 61 | 62 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /resources/fistball.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 36 | -------------------------------------------------------------------------------- /resources/floorball.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 71 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /resources/gaa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /resources/handball-net.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /resources/handball.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 16 | 20 | 21 | 27 | 33 | 37 | 41 | 45 | 53 | 98 | 102 | 103 | 104 | 110 | 116 | 120 | 124 | 128 | 136 | 181 | 185 | 186 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /resources/ice-hockey-net-nhl.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 23 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /resources/ice-hockey-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nguyenank/shot-plotter/3e6d8fa55fb0916fe256fd20d2d0f5b1cdcbc5af/resources/ice-hockey-screenshot.png -------------------------------------------------------------------------------- /resources/indoor-lacrosse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 54 | 55 | -------------------------------------------------------------------------------- /resources/korfball.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 34 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /resources/mens-lacrosse-net.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /resources/mens-lacrosse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 31 | 32 | 69 | 75 | 76 | 77 | 78 | 81 | 82 | 87 | 88 | 124 | 130 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /resources/netball-ssn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /resources/rugby-union.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 16 | 17 | 18 | 19 | 28 | 37 | 46 | 55 | 56 | 57 | 62 | 63 | 64 | 65 | 74 | 83 | 92 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /resources/soccer-ifab-m.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 23 | 24 | 25 | 29 | 32 | 36 | 40 | 41 | 42 | 48 | 54 | 55 | 56 | 60 | 63 | 67 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /resources/soccer-ifab-yd.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 23 | 24 | 25 | 29 | 32 | 36 | 40 | 41 | 42 | 48 | 54 | 55 | 56 | 60 | 63 | 67 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /resources/soccer-meerse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 16 | 17 | 18 | 19 | 25 | 26 | 32 | 38 | 42 | 46 | 49 | 53 | 57 | 58 | 59 | 65 | 71 | 75 | 79 | 82 | 86 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /resources/soccer-ncaa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 23 | 27 | 31 | 32 | 35 | 39 | 43 | 44 | 45 | 51 | 57 | 61 | 65 | 66 | 69 | 73 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /resources/soccer-net-ifab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/soccer-net-ncaa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /resources/sport-select-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nguyenank/shot-plotter/3e6d8fa55fb0916fe256fd20d2d0f5b1cdcbc5af/resources/sport-select-screenshot.png -------------------------------------------------------------------------------- /resources/table-tennis.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /resources/tennis.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /resources/volleyball.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 40 | 41 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /resources/womens-lacrosse-net.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /resources/womens-lacrosse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 55 | 58 | 59 | 60 | 67 | 100 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 118 | 119 | 152 | 155 | 156 | 157 | 164 | 197 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | import { setUpPlayingArea } from "./js/playing-area.js"; 2 | import { setUpDetailsPanel } from "./js/details/details-panel.js"; 3 | import { setUpToggles } from "./js/toggles.js"; 4 | import { setUpShots } from "./js/shots/shot.js"; 5 | import { setUpTable } from "./js/table/table.js"; 6 | import { setUpCSVDownloadUpload } from "./js/csv.js"; 7 | import { setUpLegend, shotTypeLegend } from "./js/shots/legend.js"; 8 | import { select2Dropdown } from "./js/details/widgets/widgets-special.js"; 9 | import { cfgOtherSetup } from "./js/details/config-details.js"; 10 | import { customCardSetup } from "./js/custom-setups/card-setup.js"; 11 | import { customConfigSetup } from "./js/custom-setups/config-setup.js"; 12 | 13 | export let sport; 14 | export let dataStorage; 15 | export let cfgSportCustomSetup; 16 | export let cfgSportA; 17 | export let cfgSportGoalCoords; 18 | export let cfgSportScoringArea; 19 | export let getDefaultSetup; 20 | export let cfgDefaultEnable; 21 | export let perimeterId; 22 | 23 | export function indexSetup() { 24 | d3.json("/supported-sports.json").then((data) => { 25 | const customSports = _.filter(data.sports, "needsCustomSetup"); 26 | for (const s of customSports) { 27 | customCardSetup(s); 28 | } 29 | }); 30 | } 31 | 32 | export function setCfgSportGoalCoords(newGoalCoords) { 33 | cfgSportGoalCoords = newGoalCoords; 34 | } 35 | 36 | export function setup(s) { 37 | sport = s; 38 | dataStorage = localDataStorage(sport); 39 | d3.json("/supported-sports.json").then((data) => { 40 | let sportData = _.find(data.sports, { id: sport }); 41 | cfgSportCustomSetup = false; 42 | if (sportData.needsCustomSetup) { 43 | sportData = customConfigSetup(sportData); 44 | cfgSportCustomSetup = true; 45 | } 46 | cfgSportA = sportData.appearance; 47 | cfgSportGoalCoords = sportData.goalCoords; 48 | cfgSportScoringArea = sportData.scoringArea; 49 | perimeterId = sportData.perimeter; 50 | getDefaultSetup = function () { 51 | const details = _.cloneDeep(sportData.defaultDetails); 52 | return { 53 | details: details, 54 | ...cfgOtherSetup, 55 | twoPointEnable: 56 | _.some(details, { type: "x", id: "x2" }) && 57 | _.some(details, { type: "y", id: "y2" }), 58 | }; 59 | }; 60 | cfgDefaultEnable = sportData.defaultEnable; 61 | 62 | setUpPlayingArea(); 63 | setUpDetailsPanel(); 64 | setUpToggles(); 65 | setUpTable(); 66 | setUpShots(); 67 | setUpCSVDownloadUpload(); 68 | setUpLegend(); 69 | 70 | d3.select("h1") 71 | .attr("href", "./") 72 | .on("click", () => { 73 | window.location = "./"; 74 | }); 75 | 76 | function decode(a) { 77 | return a.replace(/[a-zA-Z]/g, function (c) { 78 | return String.fromCharCode( 79 | (c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + 13) 80 | ? c 81 | : c - 26 82 | ); 83 | }); 84 | } 85 | 86 | // https://www.ionos.com/digitalguide/e-mail/e-mail-security/protecting-your-email-address-how-to-do-it/ 87 | 88 | // ROT13 encryption for email 89 | function decode(a) { 90 | return a.replace(/[a-zA-Z]/g, function (c) { 91 | return String.fromCharCode( 92 | (c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + 13) 93 | ? c 94 | : c - 26 95 | ); 96 | }); 97 | } 98 | 99 | d3.select("#email").on("click", function () { 100 | const y = "znvygb:naxathlranaxathlra@tznvy.pbz"; 101 | d3.select(this) 102 | .attr("href", decode(y)) 103 | .on("click", () => {}); 104 | }); 105 | 106 | $(document).ready(function () { 107 | select2Dropdown(); 108 | $("#shot-type-select").on("change", function (e) { 109 | // update legend 110 | shotTypeLegend(); 111 | 112 | // https://stackoverflow.com/a/54047075 113 | // do not delete new options 114 | $(this).find("option").removeAttr("data-select2-tag"); 115 | }); 116 | }); 117 | }); 118 | } 119 | --------------------------------------------------------------------------------