├── d3.basketball-shot-chart.css ├── .gitignore ├── d3_basketball_shot_chart.scss ├── LICENSE ├── README.md ├── d3.basketball-shot-chart.js └── example └── index.html /d3.basketball-shot-chart.css: -------------------------------------------------------------------------------- 1 | .shot-chart-court *{fill:transparent;stroke:#333;stroke-width:0.1}.shot-chart-court-ft-circle-bottom{stroke-dasharray:1.5, 1}.shot-chart-court-hoop,.shot-chart-court-backboard{z-index:100}.shot-chart-title{font-size:15%;text-transform:uppercase}.legend text{font-size:4%} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /lib/bundler/man/ 26 | 27 | # for a library or gem, you might want to ignore these files since the code is 28 | # intended to run in multiple environments; otherwise, check them in: 29 | # Gemfile.lock 30 | # .ruby-version 31 | # .ruby-gemset 32 | 33 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 34 | .rvmrc 35 | -------------------------------------------------------------------------------- /d3_basketball_shot_chart.scss: -------------------------------------------------------------------------------- 1 | $basketballShotChartStrokeColor: #333; 2 | $basketballShotChartStrokeWidth: .1; 3 | $basketballShotChartFillColor: transparent; 4 | $basketballShotChartEmptyColor: transparent; 5 | $basketballShotChartMiddleZIndex: 50; 6 | $basketballShotChartTopZIndex: 100; 7 | 8 | .shot-chart-court { 9 | * { 10 | fill: $basketballShotChartEmptyColor; 11 | stroke: $basketballShotChartStrokeColor; 12 | stroke-width: $basketballShotChartStrokeWidth; 13 | } 14 | } 15 | 16 | .shot-chart-court-ft-circle-bottom { 17 | stroke-dasharray: 1.5, 1; 18 | } 19 | 20 | .shot-chart-court-hoop, 21 | .shot-chart-court-backboard { 22 | z-index: $basketballShotChartTopZIndex; 23 | } 24 | 25 | .shot-chart-title { 26 | font-size: 15%; 27 | text-transform: uppercase; 28 | } 29 | 30 | .legend text { 31 | font-size: 4%; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Viraj Sanghvi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3.basketball-shot-chart 2 | 3 | This visualization aims to become a generic means of generating charts on a basketball court. Currently it only supports hexbin shot charts, with lots of flexibility, but is alpha quality and will be refactored to support other binning mechanisms and other mark types on top of a basketball court. 4 | 5 | Currently customizable: 6 | 7 | - Court dimensions/lines 8 | - Binning definition 9 | - Hexagon size range and color range 10 | - Integrating different shot chart datasets 11 | - Titles and labels 12 | 13 | ## Setup 14 | 15 | - Include ```d3.js``` 16 | - Include ```hexbin.js``` [d3.hexbin](https://github.com/virajsanghvi/d3-plugins/tree/master/hexbin) - NOTE: this is a fork 17 | - Include ```d3.chart.js``` - [d3.chart](http://misoproject.com/d3-chart/) 18 | - Include ```d3.chart.defaults.js``` [d3.chart.defaults](https://github.com/virajsanghvi/d3.chart.defaults) 19 | - Include ```d3.basketball-shot-chart.js``` 20 | - Include ```d3.basketball-shot-chart.css``` (or include the sass file) 21 | 22 | ## Examples 23 | 24 | This library is currently used to generate the shot charts at [tothemean](http://tothemean.com/tools/shot-charts), and there's a [blog post that walks through using this chart](FIXME). 25 | 26 | If you clone the repo, you'll also find a simple example in the ```example``` directory. 27 | 28 | ## To use: 29 | 30 | Generally, you likely have some shot chart data that's an array of data points representing shots, including the x, y position on the court, and whether the shot was made: 31 | 32 | ``` 33 | var data = [{"x":2,"y":9,"made":1},{"x":2,"y":8,"made":1},...]; 34 | ``` 35 | 36 | You can continue with this, or you can also self aggregate to reduce the size/complexity of the data, and capture number of makes and attempts at a location: 37 | 38 | ``` 39 | var data = [{"x":2,"y":9,"made":3,"attempts":3},{"x":2,"y":8,"made":0,"attempts":4},...]; 40 | ``` 41 | 42 | NOTE: Even in this scheme, a point for the same location can be repeated, as all points will be aggregated as part of the binning process (which is how we handle the first simple case). 43 | 44 | Once we have our data, we can quickly chart it: 45 | 46 | ```javascript 47 | var chart = d3.select(el) 48 | .append("svg") 49 | .chart("BasketballShotChart") 50 | .draw(data); 51 | ``` 52 | 53 | By default, the shot chart visualization recognizes the data structure above, but that can easily be configured with the options below. Also, by default, the heat chart is based on a range of shooting 0% to 100%. Most shot charts you've probably seen compare to the average, and its up to you to calculate that, but you can use the options below to update the range of values for the heatMap, and to make the hexagon colors or radiuses based on any value from your data you want. 54 | 55 | # Options 56 | 57 | You can pass any of these options when creating a new chart. You can change them through public setters, but the shot chart won't autoimically pick them up - yet. 58 | 59 | These are all defined in the code, and I recommend looking there for more information on how they're actually utilized. 60 | 61 | - basketDiameter: basketball hoop diameter (ft) (default: 1.5) 62 | - basketProtrusionLength: distance from baseline to backboard (ft) (default: 4) 63 | - basketWidth: backboard width (ft) (default: 6) 64 | - colorLegendTitle: title of hexagon color legend (default: 'Efficiency') 65 | - colorLegendStartLabel: label for starting of hexagon color range (default: '< avg') 66 | - colorLegendEndLabel: label for ending of hexagon color range (default: '> avg') 67 | - courtLength: full length of basketball court (ft) (default: 94) 68 | - courtWidth: full width of basketball court (ft) (default: 50) 69 | - freeThrowLineLength: distance from baseline to free throw line (ft) (default: 19) 70 | - freeThrowCircleRadius: radius of free throw line circle (ft) (default: 6) 71 | - heatScale: d3 scale for hexagon colors (default: d3 quantize scale if [0, 1] domain and colors from Goldsberry's shot charts) 72 | - height: height of svg, specifying won't change scale of chart (default: undefined) 73 | - hexagonBin: method of aggregating points into a bin (e.g. function (point, bin) {...}) (default: bins by aggregating makes and attempts from points) 74 | - hexagonBinVisibleThreshold: how many points does a bin need to be visualized (default: 1) 75 | - hexagonFillValue: method to determine value to be used with specified heatScale (e.g. function (bin) {...}) (default: returns bin.made/bin.attempts) 76 | - hexagonRadius: bin size with regards to courth width/height (ft) (default: .75) 77 | - hexagonRadiusSizes: discrete hexagon size values that radius value is mapped to, intentionally hides low frequency points (default: [0, .4, .6, .75]) 78 | - hexagonRadiusThreshold: how many points in a bin to consider it while building radius scale (default: 2) 79 | - hexagonRadiusValue: method to determine radius value to be used in radius scale (e.g. function (bin) {...}) (default: returns bin.attempts) 80 | - keyMarkWidth: width of key marks (dashes on side of the paint) (ft) (default: .5) 81 | - keyWidth: width the key (paint) (ft) (default: 16) 82 | - restrictedCircleRadius: radius of restricted circle (ft) (default: 4) 83 | - sizeLegendTitle: title of hexagon size legend (default: 'Frequency') 84 | - sizeLegendSmallLabel: label of start of hexagon size legend (default: 'low') 85 | - sizeLegendLargeLabel: label of end of hexagon size legend (default: 'high') 86 | - threePointCutoffLength: distance from baseline where three point line because circular (ft) (default: 14) 87 | - threePointRadius: distance of three point line from basket (ft) (default: 23.75) 88 | - threePointSideRadius: distance of corner three point line from basket (ft) (default: 22) 89 | - title: title of chart (default: 'Shot chart') 90 | - translateX: method to determine x position of a bin on the court (default: x value) 91 | - translateY: method to determine y position of a bin on the court (default: flips y axis to opposite side of court) 92 | - width: width of svg (default: 500) 93 | -------------------------------------------------------------------------------- /d3.basketball-shot-chart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create and configure NBA shot charts for offense and defense. 3 | * 4 | * Requires: 5 | * - d3 6 | * - d3.chart 7 | * - d3.chart.defaults 8 | */ 9 | (function () { 10 | 11 | var clipCounter = 0; 12 | 13 | var BasketballShotChart = d3.chart('BasketballShotChart', { 14 | 15 | initialize: function () { 16 | this.calculateVisibleCourtLength(); 17 | 18 | var base = this.base 19 | .attr('class', 'shot-chart'); 20 | 21 | // draw base court 22 | this.drawCourt(); 23 | 24 | // add title 25 | this.drawTitle(); 26 | 27 | // draw legend 28 | this.drawLegend(); 29 | 30 | // add data 31 | this.drawShots(); 32 | }, 33 | 34 | // helper to create an arc path 35 | appendArcPath: function (base, radius, startAngle, endAngle) { 36 | var points = 30; 37 | 38 | var angle = d3.scale.linear() 39 | .domain([0, points - 1]) 40 | .range([startAngle, endAngle]); 41 | 42 | var line = d3.svg.line.radial() 43 | .interpolate("basis") 44 | .tension(0) 45 | .radius(radius) 46 | .angle(function(d, i) { return angle(i); }); 47 | 48 | return base.append("path").datum(d3.range(points)) 49 | .attr("d", line); 50 | }, 51 | 52 | // draw basketball court 53 | drawCourt: function () { 54 | var courtWidth = this._courtWidth, 55 | visibleCourtLength = this._visibleCourtLength, 56 | keyWidth = this._keyWidth 57 | threePointRadius = this._threePointRadius, 58 | threePointSideRadius = this._threePointSideRadius, 59 | threePointCutoffLength = this._threePointCutoffLength, 60 | freeThrowLineLength = this._freeThrowLineLength, 61 | freeThrowCircleRadius = this._freeThrowCircleRadius, 62 | basketProtrusionLength = this._basketProtrusionLength, 63 | basketDiameter = this._basketDiameter, 64 | basketWidth = this._basketWidth, 65 | restrictedCircleRadius = this._restrictedCircleRadius, 66 | keyMarkWidth = this._keyMarkWidth; 67 | 68 | var base = this.base 69 | .attr('width', this._width) 70 | .attr('viewBox', "0 0 " + courtWidth + " " + visibleCourtLength) 71 | .append('g') 72 | .attr('class', 'shot-chart-court'); 73 | if (this._height) base.attr('height', this._height); 74 | 75 | base.append("rect") 76 | .attr('class', 'shot-chart-court-key') 77 | .attr("x", (courtWidth / 2 - keyWidth / 2)) 78 | .attr("y", (visibleCourtLength - freeThrowLineLength)) 79 | .attr("width", keyWidth) 80 | .attr("height", freeThrowLineLength); 81 | 82 | base.append("line") 83 | .attr('class', 'shot-chart-court-baseline') 84 | .attr("x1", 0) 85 | .attr("y1", visibleCourtLength) 86 | .attr("x2", courtWidth) 87 | .attr("y2", visibleCourtLength); 88 | 89 | var tpAngle = Math.atan(threePointSideRadius / 90 | (threePointCutoffLength - basketProtrusionLength - basketDiameter/2)); 91 | this.appendArcPath(base, threePointRadius, -1 * tpAngle, tpAngle) 92 | .attr('class', 'shot-chart-court-3pt-line') 93 | .attr("transform", "translate(" + (courtWidth / 2) + ", " + 94 | (visibleCourtLength - basketProtrusionLength - basketDiameter / 2) + 95 | ")"); 96 | 97 | [1, -1].forEach(function (n) { 98 | base.append("line") 99 | .attr('class', 'shot-chart-court-3pt-line') 100 | .attr("x1", courtWidth / 2 + threePointSideRadius * n) 101 | .attr("y1", visibleCourtLength - threePointCutoffLength) 102 | .attr("x2", courtWidth / 2 + threePointSideRadius * n) 103 | .attr("y2", visibleCourtLength); 104 | }); 105 | 106 | this.appendArcPath(base, restrictedCircleRadius, -1 * Math.PI/2, Math.PI/2) 107 | .attr('class', 'shot-chart-court-restricted-area') 108 | .attr("transform", "translate(" + (courtWidth / 2) + ", " + 109 | (visibleCourtLength - basketProtrusionLength - basketDiameter / 2) + ")"); 110 | 111 | this.appendArcPath(base, freeThrowCircleRadius, -1 * Math.PI/2, Math.PI/2) 112 | .attr('class', 'shot-chart-court-ft-circle-top') 113 | .attr("transform", "translate(" + (courtWidth / 2) + ", " + 114 | (visibleCourtLength - freeThrowLineLength) + ")"); 115 | 116 | this.appendArcPath(base, freeThrowCircleRadius, Math.PI/2, 1.5 * Math.PI) 117 | .attr('class', 'shot-chart-court-ft-circle-bottom') 118 | .attr("transform", "translate(" + (courtWidth / 2) + ", " + 119 | (visibleCourtLength - freeThrowLineLength) + ")"); 120 | 121 | [7, 8, 11, 14].forEach(function (mark) { 122 | [1, -1].forEach(function (n) { 123 | base.append("line") 124 | .attr('class', 'shot-chart-court-key-mark') 125 | .attr("x1", courtWidth / 2 + keyWidth / 2 * n + keyMarkWidth * n) 126 | .attr("y1", visibleCourtLength - mark) 127 | .attr("x2", courtWidth / 2 + keyWidth / 2 * n) 128 | .attr("y2", visibleCourtLength - mark) 129 | }); 130 | }); 131 | 132 | base.append("line") 133 | .attr('class', 'shot-chart-court-backboard') 134 | .attr("x1", courtWidth / 2 - basketWidth / 2) 135 | .attr("y1", visibleCourtLength - basketProtrusionLength) 136 | .attr("x2", courtWidth / 2 + basketWidth / 2) 137 | .attr("y2", visibleCourtLength - basketProtrusionLength) 138 | 139 | base.append("circle") 140 | .attr('class', 'shot-chart-court-hoop') 141 | .attr("cx", courtWidth / 2) 142 | .attr("cy", visibleCourtLength - basketProtrusionLength - basketDiameter / 2) 143 | .attr("r", basketDiameter / 2) 144 | }, 145 | 146 | // add title to svg 147 | drawTitle: function () { 148 | this.base.append("text") 149 | .classed('shot-chart-title', true) 150 | .attr("x", (this._courtWidth / 2)) 151 | .attr("y", (this._courtLength / 2 - this._visibleCourtLength) / 3) 152 | .attr("text-anchor", "middle") 153 | .text(this._title); 154 | }, 155 | 156 | // add legends to svg 157 | drawLegend: function () { 158 | var courtWidth = this._courtWidth, 159 | visibleCourtLength = this._visibleCourtLength, 160 | heatScale = this._heatScale, 161 | hexagonRadiusSizes = this._hexagonRadiusSizes, 162 | hexagonFillValue = this._hexagonFillValue, 163 | keyWidth = this._keyWidth, 164 | basketProtrusionLength = this._basketProtrusionLength; 165 | 166 | var heatRange = heatScale.range(); 167 | var largestHexagonRadius = hexagonRadiusSizes[hexagonRadiusSizes.length - 1]; 168 | var colorXMid = courtWidth - 169 | (threePointSideRadius - keyWidth / 2) / 2 - 170 | (courtWidth / 2 - threePointSideRadius); 171 | var colorXStart = colorXMid - (heatRange.length * largestHexagonRadius); 172 | var colorYStart = visibleCourtLength - basketProtrusionLength/3; 173 | var hexbin = d3.hexbin(); 174 | var hexagon = hexbin.hexagon(largestHexagonRadius); 175 | var colorLegend = this.base.append('g') 176 | .classed('legend', true); 177 | colorLegend.append("text") 178 | .classed('legend-title', true) 179 | .attr("x", colorXMid) 180 | .attr("y", colorYStart - largestHexagonRadius * 2) 181 | .attr("text-anchor", "middle") 182 | .text(this._colorLegendTitle); 183 | colorLegend.append("text") 184 | .attr("x", colorXStart) 185 | .attr("y", colorYStart) 186 | .attr("text-anchor", "end") 187 | .text(this._colorLegendStartLabel); 188 | colorLegend.append("text") 189 | .attr("x", colorXStart + heatRange.length * 2 * largestHexagonRadius) 190 | .attr("y", colorYStart) 191 | .attr("text-anchor", "start") 192 | .text(this._colorLegendEndLabel); 193 | colorLegend.selectAll('path').data(heatRange) 194 | .enter() 195 | .append('path') 196 | .attr('d', hexagon) 197 | .attr("transform", function (d, i) { 198 | return "translate(" + 199 | (colorXStart + ((1 + i*2) *largestHexagonRadius)) + ", " + 200 | (colorYStart) + ")"; 201 | }) 202 | .style('fill', function (d, i) { return d; }); 203 | 204 | 205 | var sizeRange = hexagonRadiusSizes.slice(-3); 206 | var sizeLengendWidth = 0; 207 | for (var i = 0, l = sizeRange.length; i < l; ++i) { 208 | sizeLengendWidth += sizeRange[i] * 2; 209 | } 210 | var sizeXMid = (threePointSideRadius - keyWidth / 2) / 2 + 211 | (courtWidth / 2 - threePointSideRadius); 212 | var sizeXStart = sizeXMid - (sizeLengendWidth / 2); 213 | var sizeYStart = visibleCourtLength - basketProtrusionLength/3; 214 | var sizeLegend = this.base.append('g') 215 | .classed('legend', true); 216 | sizeLegend.append("text") 217 | .classed('legend-title', true) 218 | .attr("x", sizeXMid) 219 | .attr("y", sizeYStart - largestHexagonRadius * 2) 220 | .attr("text-anchor", "middle") 221 | .text(this._sizeLegendTitle); 222 | sizeLegend.append("text") 223 | .attr("x", sizeXStart) 224 | .attr("y", sizeYStart) 225 | .attr("text-anchor", "end") 226 | .text(this._sizeLegendSmallLabel); 227 | sizeLegend.selectAll('path').data(sizeRange) 228 | .enter() 229 | .append('path') 230 | .attr('d', function (d) { return hexbin.hexagon(d); }) 231 | .attr("transform", function (d, i) { 232 | sizeXStart += d * 2; 233 | return "translate(" + 234 | (sizeXStart - d) + ", " + 235 | sizeYStart + ")"; 236 | }) 237 | .style('fill', '#999'); 238 | sizeLegend.append("text") 239 | .attr("x", sizeXStart) 240 | .attr("y", sizeYStart) 241 | .attr("text-anchor", "start") 242 | .text(this._sizeLegendLargeLabel); 243 | }, 244 | 245 | // draw hexagons on court 246 | drawShots: function () { 247 | var courtWidth = this._courtWidth, 248 | visibleCourtLength = this._visibleCourtLength, 249 | hexagonRadius = this._hexagonRadius, 250 | heatScale = this._heatScale, 251 | hexagonBinVisibleThreshold = this._hexagonBinVisibleThreshold, 252 | hexagonRadiusThreshold = this._hexagonRadiusThreshold, 253 | hexagonRadiusSizes = this._hexagonRadiusSizes, 254 | hexagonRadiusValue = this._hexagonRadiusValue, 255 | hexagonFillValue = this._hexagonFillValue, 256 | radiusScale; 257 | 258 | // bin all shots into hexagons 259 | var hexbin = d3.hexbin() 260 | .size([courtWidth, visibleCourtLength]) 261 | .radius(hexagonRadius) 262 | .x(this._translateX.bind(this)) 263 | .y(this._translateY.bind(this)) 264 | .bin(this._hexagonBin); 265 | 266 | // create layerBase 267 | var layerBase = this.base.append('g'); 268 | 269 | // append clip to prevent showing data outside range 270 | clipCounter += 1; 271 | var clipId = 'bbs-clip-' + clipCounter; 272 | layerBase.append('clipPath') 273 | .attr('id', clipId) 274 | .append("rect") 275 | .attr("class", "shot-chart-mesh") 276 | .attr("width", courtWidth) 277 | .attr("height", visibleCourtLength); 278 | 279 | // add layer 280 | this.layer('hexagons', layerBase, { 281 | 282 | dataBind: function (data) { 283 | // subset bins to ones that meet threshold parameters 284 | var allHexbinPoints = hexbin(data); 285 | var hexbinPoints = []; 286 | var hexbinQuantities = []; 287 | for (var i = 0, l = allHexbinPoints.length; i < l; ++i) { 288 | var pts = allHexbinPoints[i]; 289 | var numPts = 0; 290 | for (var j = 0, jl = pts.length; j < jl; ++j) { 291 | numPts += pts[j].attempts || 1; 292 | } 293 | if (numPts > hexagonBinVisibleThreshold) hexbinPoints.push(pts); 294 | if (numPts > hexagonRadiusThreshold) hexbinQuantities.push(numPts); 295 | } 296 | 297 | // create radius scale 298 | radiusScale = d3.scale.quantile() 299 | .domain(hexbinQuantities) 300 | .range(hexagonRadiusSizes) 301 | 302 | return this.append('g') 303 | .attr('clip-path', 'url(#' + clipId + ')') 304 | .selectAll('.hexagon') 305 | .data(hexbinPoints); 306 | }, 307 | 308 | insert: function () { 309 | return this.append('path') 310 | .classed('shot-chart-hexagon', true); 311 | }, 312 | 313 | events: { 314 | 315 | enter: function () { 316 | this.attr('transform', function(d) { 317 | return "translate(" + d.x + "," + d.y + ")"; 318 | }); 319 | }, 320 | 321 | merge: function () { 322 | this 323 | .attr('d', function(d) { 324 | var val = radiusScale(hexagonRadiusValue(d)) 325 | if (val > 0) return hexbin.hexagon(val); 326 | }) 327 | .style('fill', function(d) { 328 | return heatScale(hexagonFillValue(d)); 329 | }); 330 | }, 331 | 332 | exit: function () { 333 | this.remove(); 334 | } 335 | 336 | }, 337 | 338 | }); 339 | 340 | }, 341 | 342 | // redraw chart 343 | redraw: function () { 344 | if (this.data) this.draw(this.data); 345 | }, 346 | 347 | // on court length change, recalculate length of visible court 348 | calculateVisibleCourtLength: function () { 349 | var halfCourtLength = this._courtLength / 2; 350 | var threePointLength = this._threePointRadius + 351 | this._basketProtrusionLength; 352 | this._visibleCourtLength = threePointLength + 353 | (halfCourtLength - threePointLength) / 2; 354 | }, 355 | 356 | }); 357 | 358 | d3.chart.initializeDefaults(BasketballShotChart, { 359 | // basketball hoop diameter (ft) 360 | basketDiameter: 1.5, 361 | // distance from baseline to backboard (ft) 362 | basketProtrusionLength: 4, 363 | // backboard width (ft) 364 | basketWidth: 6, 365 | // title of hexagon color legend 366 | colorLegendTitle: 'Efficiency', 367 | // label for starting of hexagon color range 368 | colorLegendStartLabel: '< avg', 369 | // label for ending of hexagon color range 370 | colorLegendEndLabel: '> avg', 371 | // full length of basketball court (ft) 372 | courtLength: 94, 373 | // full width of basketball court (ft) 374 | courtWidth: 50, 375 | // distance from baseline to free throw line (ft) 376 | freeThrowLineLength: 19, 377 | // radius of free throw line circle (ft) 378 | freeThrowCircleRadius: 6, 379 | // d3 scale for hexagon colors 380 | heatScale: d3.scale.quantize() 381 | .domain([0, 1]) 382 | .range(['#5458A2', '#6689BB', '#FADC97', '#F08460', '#B02B48']), 383 | // height of svg 384 | height: undefined, 385 | // method of aggregating points into a bin 386 | hexagonBin: function (point, bin) { 387 | var attempts = point.attempts || 1; 388 | var made = +point.made || 0; 389 | bin.attempts = (bin.attempts || 0) + attempts; 390 | bin.made = (bin.made || 0) + made; 391 | }, 392 | // how many points does a bin need to be visualized 393 | hexagonBinVisibleThreshold: 1, 394 | // method to determine value to be used with specified heatScale 395 | hexagonFillValue: function(d) { return d.made/d.attempts; }, 396 | // bin size with regards to courth width/height (ft) 397 | hexagonRadius: .75, 398 | // discrete hexagon size values that radius value is mapped to 399 | hexagonRadiusSizes: [0, .4, .6, .75], 400 | // how many points in a bin to consider it while building radius scale 401 | hexagonRadiusThreshold: 2, 402 | // method to determine radius value to be used in radius scale 403 | hexagonRadiusValue: function (d) { return d.attempts; }, 404 | // width of key marks (dashes on side of the paint) (ft) 405 | keyMarkWidth: .5, 406 | // width the key (paint) (ft) 407 | keyWidth: 16, 408 | // radius of restricted circle (ft) 409 | restrictedCircleRadius: 4, 410 | // title of hexagon size legend 411 | sizeLegendTitle: 'Frequency', 412 | // label of start of hexagon size legend 413 | sizeLegendSmallLabel: 'low', 414 | // label of end of hexagon size legend 415 | sizeLegendLargeLabel: 'high', 416 | // distance from baseline where three point line because circular (ft) 417 | threePointCutoffLength: 14, 418 | // distance of three point line from basket (ft) 419 | threePointRadius: 23.75, 420 | // distance of corner three point line from basket (ft) 421 | threePointSideRadius: 22, 422 | // title of chart 423 | title: 'Shot chart', 424 | // method to determine x position of a bin on the court 425 | translateX: function (d) { return d.x; }, 426 | // method to determine y position of a bin on the court 427 | translateY: function (d) { return this._visibleCourtLength - d.y; }, 428 | // width of svg 429 | width: 500, 430 | }); 431 | 432 | })() 433 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
20 | d3.select(document.getElementById('chart'))
21 | .append("svg")
22 | .chart("BasketballShotChart", {
23 | width: 600,
24 | title: 'Lebron James 2013-14',
25 | hexagonFillValue: function(d) { return d.z; },
26 | // reverse the heat range to map our z values to other colors
27 | heatScale: d3.scale.quantile()
28 | .domain([-2.5, 2.5])
29 | .range(['#5458A2', '#6689BB', '#FADC97', '#F08460', '#B02B48']),
30 | hexagonBin: function (point, bin) {
31 | var currentZ = bin.z || 0;
32 | var totalAttempts = bin.attempts || 0;
33 | var totalZ = currentZ * totalAttempts;
34 |
35 | var attempts = point.attempts || 1;
36 | bin.attempts = totalAttempts + attempts;
37 | bin.z = (totalZ + (point.z * attempts))/bin.attempts;
38 | },
39 | // update radius threshold to at least 4 shots to clean up the chart
40 | hexagonRadiusThreshold: 4,
41 | })
42 | .draw(data);
43 |
44 |
45 |
46 |