UNPKG

d3-soccer

Version:

A d3 plugin to visualize soccer data.

1,391 lines (1,300 loc) 69.9 kB
// https://github.com/probberechts/d3-soccer v0.2.0 Copyright 2024 undefined (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-selection'), require('d3-shape'), require('d3-color'), require('d3-scale'), require('d3-scale-chromatic')) : typeof define === 'function' && define.amd ? define(['exports', 'd3-selection', 'd3-shape', 'd3-color', 'd3-scale', 'd3-scale-chromatic'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3 = global.d3 || {}, global.d3, global.d3, global.d3, global.d3, global.d3)); })(this, (function (exports, d3Selection, d3Shape, d3Color, d3Scale, colorScale) { 'use strict'; function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var colorScale__namespace = /*#__PURE__*/_interopNamespaceDefault(colorScale); var pitchLenght = 105; var pitchWidth = 68; var spadlResults = [{ label: "Fail" }, { label: "Success" }, { label: "Offside" }, { label: "Own goal" }, { label: "Yellow card" }, { label: "Red card" }]; var spadlActionTypes = [{ label: "Pass" }, { label: "Cross" }, { label: "Throw in" }, { label: "Freekick (cross)" }, { label: "Freekick (short)" }, { label: "Corner (cross)" }, { label: "Corner (short)" }, { label: "Dribble" }, { label: "Foul" }, { label: "Tackle" }, { label: "Interception" }, { label: "Shot" }, { label: "Penalty" }, { label: "Freekick (shot)" }, { label: "Save" }, { label: "Claim" }, { label: "Punch" }, { label: "Pick up" }, { label: "Clearance" }, { label: "Bad touch" }, { label: "-" }, { label: "Carry" }, { label: "Goal kick" }]; var spadlBodyparts = [{ label: "Foot" }, { label: "Head" }, { label: "Other" }]; function pitch () { var clip = { top: 0, right: pitchLenght, bottom: pitchWidth, left: 0 }; var height = 300; var rotated = false; var width = (-clip.left + clip.right) / (-clip.top + clip.bottom) * height; var pitchstrokewidth = 0.5; var dirOfPlay = false; var shadeMiddleThird = false; var drawGoalsFn = drawGoalsAsLine; function drawGoalsAsBox(lines) { lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", -2).attr("y", pitchWidth / 2 - 3.66).attr("width", 2).attr("height", 7.32); lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", pitchLenght).attr("y", pitchWidth / 2 - 3.66).attr("width", 2).attr("height", 7.32); } function drawGoalsAsLine(lines) { lines.append("rect").attr("stroke-width", 0).attr("x", -pitchstrokewidth * 1.5).attr("y", pitchWidth / 2 - 3.66).attr("width", pitchstrokewidth * 3).attr("height", 7.32); lines.append("rect").attr("stroke-width", 0).attr("x", pitchLenght - pitchstrokewidth * 1.5).attr("y", pitchWidth / 2 - 3.66).attr("width", pitchstrokewidth * 3).attr("height", 7.32); } function chart(selection) { selection.each(function () { var pitch = d3Selection.select(this).append("svg").attr("width", width).attr("height", height).attr("viewBox", function () { var width = clip.right - clip.left; var height = clip.bottom - clip.top; var xdim, ydim, xpad, ypad; if (rotated) { xpad = height === pitchWidth ? 4 : 2; ypad = width === pitchLenght ? 4 : 2; xdim = -clip.left + clip.right + ypad; ydim = -clip.top + clip.bottom + xpad; } else { xpad = height === pitchWidth ? 4 : 2; ypad = width === pitchLenght ? 4 : 2; ydim = -clip.top + clip.bottom + xpad; xdim = -clip.left + clip.right + ypad; } return "-2 -2 ".concat(xdim, " ").concat(ydim); }).append("g").attr("class", "pitch").attr("transform", "translate(".concat(-clip.left, ", ").concat(-clip.top, ")rotate(").concat(rotated ? -90 : 0, " 0 0)translate(").concat(rotated ? -105 : 0, " 0)")); pitch.append("g").attr("class", "below"); var lines = pitch.append("g").attr("class", "lines").attr("stroke", "#000").attr("fill", "#000").attr("pointer-events", "none"); // Halfway line lines.append("line").attr("stroke-width", pitchstrokewidth).attr("x1", pitchLenght / 2).attr("y1", 0).attr("x2", pitchLenght / 2).attr("y2", pitchWidth); // Centre circle lines.append("circle").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("cx", pitchLenght / 2).attr("cy", pitchWidth / 2).attr("r", 9.15); lines.append("circle").attr("stroke-width", 0).attr("cx", pitchLenght / 2).attr("cy", pitchWidth / 2).attr("r", pitchstrokewidth); // Penalty arcs var arc1 = d3Shape.arc().innerRadius(9.15).outerRadius(9.15).startAngle(38 * (Math.PI / 180)) //converting from degs to radians .endAngle(142 * (Math.PI / 180)); //just radians lines.append("path").attr("stroke-width", pitchstrokewidth).attr("d", arc1).attr("transform", "translate(11," + pitchWidth / 2 + ")"); var arc2 = d3Shape.arc().innerRadius(9.15).outerRadius(9.15).startAngle(218 * (Math.PI / 180)) //converting from degs to radians .endAngle(322 * (Math.PI / 180)); //just radians lines.append("path").attr("stroke-width", pitchstrokewidth).attr("d", arc2).attr("transform", "translate(" + (pitchLenght - 11) + "," + pitchWidth / 2 + ")"); // Goal areas lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", 0).attr("y", pitchWidth / 2 - 9.16).attr("width", 5.5).attr("height", 18.32); lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", pitchLenght - 5.5).attr("y", pitchWidth / 2 - 9.16).attr("width", 5.5).attr("height", 18.32); // Penalty areas lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", 0).attr("y", pitchWidth / 2 - 20.16).attr("width", 16.5).attr("height", 40.32); lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", pitchLenght - 16.5).attr("y", pitchWidth / 2 - 20.16).attr("width", 16.5).attr("height", 40.32); // Penalty marks lines.append("circle").attr("stroke-width", 0).attr("cx", 11).attr("cy", pitchWidth / 2).attr("r", pitchstrokewidth); lines.append("circle").attr("stroke-width", 0).attr("cx", pitchLenght - 11).attr("cy", pitchWidth / 2).attr("r", pitchstrokewidth); // Direction of play if (dirOfPlay) { lines.append("polygon").attr("opacity", 0.1).attr("stroke-width", 0).attr("class", "shaded").attr("points", "\n 25,".concat(pitchWidth / 2 - 2, " \n 35,").concat(pitchWidth / 2 - 2, " \n 35,").concat(pitchWidth / 2 - 5, " \n 40,").concat(pitchWidth / 2, " \n 35,").concat(pitchWidth / 2 + 5, " \n 35,").concat(pitchWidth / 2 + 2, " \n 25,").concat(pitchWidth / 2 + 2, " \n 25,").concat(pitchWidth / 2 - 2, "\n ")); } // Pitch boundaries lines.append("rect").attr("stroke-width", pitchstrokewidth).attr("fill", "none").attr("x", 0).attr("y", 0).attr("width", pitchLenght).attr("height", pitchWidth); // Goals drawGoalsFn(lines); pitch.append("g").attr("class", "above"); // Middle third if (shadeMiddleThird) { lines.append("rect").attr("opacity", 0.1).attr("class", "shaded").attr("x", 35).attr("y", 0).attr("width", 35).attr("height", pitchWidth); } }); } chart.height = function (_) { if (!arguments.length) return height; height = +_; width = (-clip.left + clip.right) / (-clip.top + clip.bottom) * height; return chart; }; chart.width = function () { return width; }; chart.rotate = function (_) { if (!arguments.length) return rotated; rotated = Boolean(_); return chart; }; chart.showDirOfPlay = function (_) { if (!arguments.length) return dirOfPlay; dirOfPlay = Boolean(_); return chart; }; chart.shadeMiddleThird = function (_) { if (!arguments.length) return shadeMiddleThird; shadeMiddleThird = Boolean(_); return chart; }; chart.pitchStrokeWidth = function (_) { if (!arguments.length) return pitchstrokewidth; pitchstrokewidth = +_; return chart; }; chart.goals = function (_) { if (!arguments.length) return drawGoalsFn; if (_ === "box") drawGoalsFn = drawGoalsAsBox;else if (_ === "line") drawGoalsFn = drawGoalsAsLine;else drawGoalsFn = _; return chart; }; chart.clip = function (_) { if (!arguments.length) return [[clip.left, clip.top], [clip.right, clip.bottom]]; clip = { top: _[0][1], bottom: _[1][1], left: _[0][0], right: _[1][0] }; width = (-clip.left + clip.right) / (-clip.top + clip.bottom) * height; return chart; }; return chart; } function extent(values, valueof) { let min; let max; if (valueof === undefined) { for (const value of values) { if (value != null) { if (min === undefined) { if (value >= value) min = max = value; } else { if (min > value) min = value; if (max < value) max = value; } } } } else { let index = -1; for (let value of values) { if ((value = valueof(value, ++index, values)) != null) { if (min === undefined) { if (value >= value) min = max = value; } else { if (min > value) min = value; if (max < value) max = value; } } } } return [min, max]; } function max(values, valueof) { let max; if (valueof === undefined) { for (const value of values) { if (value != null && (max < value || (max === undefined && value >= value))) { max = value; } } } else { let index = -1; for (let value of values) { if ((value = valueof(value, ++index, values)) != null && (max < value || (max === undefined && value >= value))) { max = value; } } } return max; } var bicubicInterpolate; var hasRequiredBicubicInterpolate; function requireBicubicInterpolate () { if (hasRequiredBicubicInterpolate) return bicubicInterpolate; hasRequiredBicubicInterpolate = 1; // Interpolate values for a function of the form `f(x, y) = z` within a 1x1 square (from (x, y) = (0, 0) to (1, 1)) by providing 16 points at it's edges and around it ((-1, -1), (0, -1), (1, -1), (2, -1), (0, -1), (0, 0), ..., (3, 3)). // Takes in the 16 values for z as a 2D array and an options object. Make sure the first selector corresponds to x position (e.g. points[x][y]). This is the rotated from the visual layout if you declare the array in javascript manually. Also, keep in mind that values[0][0] actually corresponds to the value for (-1, -1). // You can set `options.scaleX` and `options.scaleY` to multiply the positions input to the interpolator by before interpolating. defaults = 1 // You can set `options.translateX` and `options.translateY` to add to the positions input to the interpolator before interpolating (but after scaling). defaults = 0 // Returns a function that takes two arguments (x, y) and returns the interpolated value. function createInterpolator(values, options={}) { options = Object.assign({ extrapolate: false, scaleX: 1, scaleY: 1, translateX: 0, translateY: 0 }, options); //Coefficients: first number corresponds to x's exponent in the polynomial, the second to y's. const a00 = values[1][1], a01 = (-1/2)*values[1][0] + (1/2)*values[1][2], a02 = values[1][0] + (-5/2)*values[1][1] + 2*values[1][2] + (-1/2)*values[1][3], a03 = (-1/2)*values[1][0] + (3/2)*values[1][1] + (-3/2)*values[1][2] + (1/2)*values[1][3], a10 = (-1/2)*values[0][1] + (1/2)*values[2][1], a11 = (1/4)*values[0][0] + (-1/4)*values[0][2] + (-1/4)*values[2][0] + (1/4)*values[2][2], a12 = (-1/2)*values[0][0] + (5/4)*values[0][1] + (-1)*values[0][2] + (1/4)*values[0][3] + (1/2)*values[2][0] + (-5/4)*values[2][1] + values[2][2] + (-1/4)*values[2][3], a13 = (1/4)*values[0][0] + (-3/4)*values[0][1] + (3/4)*values[0][2] + (-1/4)*values[0][3] + (-1/4)*values[2][0] + (3/4)*values[2][1] + (-3/4)*values[2][2] + (1/4)*values[2][3], a20 = values[0][1] + (-5/2)*values[1][1] + 2*values[2][1] + (-1/2)*values[3][1], a21 = (-1/2)*values[0][0] + (1/2)*values[0][2] + (5/4)*values[1][0] + (-5/4)*values[1][2] + (-1)*values[2][0] + values[2][2] + (1/4)*values[3][0] + (-1/4)*values[3][2], a22 = values[0][0] + (-5/2)*values[0][1] + 2*values[0][2] + (-1/2)*values[0][3] + (-5/2)*values[1][0] + (25/4)*values[1][1] + (-5)*values[1][2] + (5/4)*values[1][3] + 2*values[2][0] + (-5)*values[2][1] + 4*values[2][2] + (-1)*values[2][3] + (-1/2)*values[3][0] + (5/4)*values[3][1] + (-1)*values[3][2] + (1/4)*values[3][3], a23 = (-1/2)*values[0][0] + (3/2)*values[0][1] + (-3/2)*values[0][2] + (1/2)*values[0][3] + (5/4)*values[1][0] + (-15/4)*values[1][1] + (15/4)*values[1][2] + (-5/4)*values[1][3] + (-1)*values[2][0] + 3*values[2][1] + (-3)*values[2][2] + values[2][3] + (1/4)*values[3][0] + (-3/4)*values[3][1] + (3/4)*values[3][2] + (-1/4)*values[3][3], a30 = (-1/2)*values[0][1] + (3/2)*values[1][1] + (-3/2)*values[2][1] + (1/2)*values[3][1], a31 = (1/4)*values[0][0] + (-1/4)*values[0][2] + (-3/4)*values[1][0] + (3/4)*values[1][2] + (3/4)*values[2][0] + (-3/4)*values[2][2] + (-1/4)*values[3][0] + (1/4)*values[3][2], a32 = (-1/2)*values[0][0] + (5/4)*values[0][1] + (-1)*values[0][2] + (1/4)*values[0][3] + (3/2)*values[1][0] + (-15/4)*values[1][1] + 3*values[1][2] + (-3/4)*values[1][3] + (-3/2)*values[2][0] + (15/4)*values[2][1] + (-3)*values[2][2] + (3/4)*values[2][3] + (1/2)*values[3][0] + (-5/4)*values[3][1] + values[3][2] + (-1/4)*values[3][3], a33 = (1/4)*values[0][0] + (-3/4)*values[0][1] + (3/4)*values[0][2] + (-1/4)*values[0][3] + (-3/4)*values[1][0] + (9/4)*values[1][1] + (-9/4)*values[1][2] + (3/4)*values[1][3] + (3/4)*values[2][0] + (-9/4)*values[2][1] + (9/4)*values[2][2] + (-3/4)*values[2][3] + (-1/4)*values[3][0] + (3/4)*values[3][1] + (-3/4)*values[3][2] + (1/4)*values[3][3]; return (x, y) => { x = (x * options.scaleX) + options.translateX; y = (y * options.scaleY) + options.translateY; if(x < 0 || y < 0 || x > 1 || y > 1) throw 'cannot interpolate outside the square from (0, 0) to (1, 1): (' + x + ', ' + y + ')'; const x2 = x*x, x3 = x*x2, y2 = y*y, y3 = y*y2; return (a00 + a01*y + a02*y2 + a03*y3) + (a10 + a11*y + a12*y2 + a13*y3) * x + (a20 + a21*y + a22*y2 + a23*y3) * x2 + (a30 + a31*y + a32*y2 + a33*y3) * x3; } } // Interpolate values for a function of the form `f(x, y) = z` within an (m-3)x(n-3) rectangle (from (x, y) = (1, 1) to (m-2, n-2)) by providing m x n samples for z. // Takes in the m x n values for z as a 2D array and an options object. If `options.extrapolate` is true, this will get modified. Make sure the first selector corresponds to x position (e.g. points[x][y]). This is the rotated from the visual layout if you declare the array in javascript manually. Also, keep in mind that for this function, values[0][0] actually does corresponds to the value for (0, 0). // If `options.extrapolate` is true, the `values` arrays will be modified to allow for interpolating from (-1, -1) to (m, n) by linearly estimating a margin of 2 more values on each side. This is useful, for example, to interpolate values of an image all the way to the edge of the pixels on the edge (corresponding to -0.5 < x < m-0.5 and -0.5 < y < n-0.5). default = false // You can set `options.scaleX` and `options.scaleY` to multiply the positions input to the interpolators by before interpolating. defaults = 1 // You can set `options.translateX` and `options.translateY` to add to the positions input to the interpolators before interpolating (but after scaling). defaults = 0 // Returns a function that takes two arguments (x, y) and returns the interpolated value. function createGridInterpolator(values, options={}) { options = Object.assign({ extrapolate: false, scaleX: 1, scaleY: 1, translateX: 0, translateY: 0 }, options); const m = values.length; const n = values[0].length; const interpolators = []; if(options.extrapolate) { //Extrapolate X values[-2] = []; values[-1] = []; values[m] = []; values[m+1] = []; for(var y = 0; y < n; y++) { const leftDelta = values[0][y] - values[1][y]; const rightDelta = values[m-1][y] - values[m-2][y]; values[-2][y] = values[0][y] + 2*leftDelta; values[-1][y] = values[0][y] + leftDelta; values[m][y] = values[m-1][y] + rightDelta; values[m+1][y] = values[m-1][y] + 2*rightDelta; } //Extrapolate Y for(var x = -2; x < m+2; x++) { const bottomDelta = values[x][0] - values[x][1]; const topDelta = values[x][n-1] - values[x][n-2]; values[x][-2] = values[x][0] + 2*bottomDelta; values[x][-1] = values[x][0] + bottomDelta; values[x][n] = values[x][n-1] + topDelta; values[x][n+1] = values[x][n-1] + 2*topDelta; } //Populate interpolator arrays for(var x = -1; x < m; x++) interpolators[x] = []; }else { //Populate interpolator arrays for(var x = 1; x < m-2; x++) interpolators[x] = []; } return (x, y) => { x = (x * options.scaleX) + options.translateX; y = (y * options.scaleY) + options.translateY; if(options.extrapolate) { if(x < -1 || y < -1 || x > m || y > n) throw 'cannot interpolate outside the rectangle from (-1, -1) to (' + m + ', ' + n + ') even when extrapolating: (' + x + ', ' + y + ')'; }else { if(x < 1 || y < 1 || x > m-2 || y > n-2) throw 'cannot interpolate outside the rectangle from (1, 1) to (' + (m-2) + ', ' + (n-2) + '): (' + x + ', ' + y + '), you might want to enable extrapolating'; } var blX = Math.floor(x);// The position of interpolator's (0, 0) for this point var blY = Math.floor(y); if(options.extrapolate) {//If you're trying to interpolate on the top or right edges of what can be interpolated, you have to interpolate in the region to the left or bottom respectively. if(x === m) blX--; if(y === n) blY--; }else { if(x === m-2) blX--; if(y === n-2) blY--; } if(!interpolators[blX][blY]) { interpolators[blX][blY] = createInterpolator([ [values[blX-1][blY-1], values[blX-1][blY], values[blX-1][blY+1], values[blX-1][blY+2]], [values[blX+0][blY-1], values[blX+0][blY], values[blX][blY+1], values[blX][blY+2]], [values[blX+1][blY-1], values[blX+1][blY], values[blX+1][blY+1], values[blX+1][blY+2]], [values[blX+2][blY-1], values[blX+2][blY], values[blX+2][blY+1], values[blX+2][blY+2]] ], { translateX: -blX, translateY: -blY }); } const interpolator = interpolators[blX][blY]; return interpolator(x, y); } } // These are just helper functions for when you need to interpolate multiple sets of values (e.g. components of color in an image). // Instead of taking in 2D value arrays they expect 3D arrays, where the last index corresponds to the set/ surface (e.g. values[x][y][s]) function createMultiInterpolator(values, options={}) { const s = values[0][0].length; const interpolators = []; for(var i = 0; i < s; i++) interpolators[i] = createInterpolator(values.map(col => col.map(vals => vals[i])), options); return (x, y) => { return interpolators.map(interpolator => interpolator(x, y)); } } function createMultiGridInterpolator(values, options={}) { const s = values[0][0].length; const interpolators = []; for(var i = 0; i < s; i++) interpolators[i] = createGridInterpolator(values.map(col => col.map(vals => vals[i])), options); return (x, y) => { return interpolators.map(interpolator => interpolator(x, y)); } } bicubicInterpolate = { createInterpolator: createInterpolator, createGridInterpolator: createGridInterpolator, createMultiInterpolator: createMultiInterpolator, createMultiGridInterpolator: createMultiGridInterpolator }; return bicubicInterpolate; } var bicubicInterpolateExports = requireBicubicInterpolate(); function position(el, parent) { var elPos = el.node().getBoundingClientRect(); var vpPos = parent.node().getBoundingClientRect(); return { top: elPos.top - vpPos.top, left: elPos.left - vpPos.left, width: elPos.width, bottom: elPos.bottom - vpPos.top, height: elPos.height, right: elPos.right - vpPos.left }; } var grid = function grid() { function grid(d) { var data = []; var gx = d.length; var gy = d[0].length; var incx = pitchLenght / gx; var incy = pitchWidth / gy; var max_v = 0; for (var x = 0; x < gx; x++) { for (var y = 0; y < gy; y++) { if (d[x][y] > max_v) { max_v = d[x][y]; } data.push({ i: x, j: y, x: x * incx, y: y * incy, width: incx, height: incy, value: d[x][y] }); } } return data; } return grid; }; var rectbin = function rectbin() { var dx = 0.1, dy = 0.1, x = function x(d) { return d[0]; }, y = function y(d) { return d[1]; }; function trunc(x) { return x < 0 ? Math.ceil(x) : Math.floor(x); } function rectbin(points) { var binsById = {}; var xExtent = [0, 105]; var yExtent = [0, 68]; //var xExtent = d3.extent(points, function(d, i){ return x.call(rectbin, d, i) ;}); //var yExtent = d3.extent(points, function(d, i){ return y.call(rectbin, d, i) ;}); for (var Y = yExtent[0], pj = 0; Y < yExtent[1] - 0.0001; Y += dy, pj++) { for (var X = xExtent[0], pi = 0; X < xExtent[1] - 0.0001; X += dx, pi++) { var id = pi + "-" + pj; var bin = binsById[id] = []; bin.i = pi; bin.j = pj; bin.x = X; bin.y = Y; bin.width = dx; bin.height = dy; bin.value = 0; } } points.forEach(function (point, i) { var py = y.call(rectbin, point, i) / dy; var pj = trunc(py); var px = x.call(rectbin, point, i) / dx; var pi = trunc(px); var id = pi + "-" + pj; var bin = binsById[id]; if (bin) { bin.push(point); bin.value += 1; } }); return Object.values(binsById); } rectbin.x = function (_) { if (!arguments.length) return x; x = _; return rectbin; }; rectbin.y = function (_) { if (!arguments.length) return y; y = _; return rectbin; }; rectbin.dx = function (_) { if (!arguments.length) return dx; dx = _; return rectbin; }; rectbin.dy = function (_) { if (!arguments.length) return dy; dy = _; return rectbin; }; return rectbin; }; function heatmap (pitch) { var enableInteraction = false, selected = [undefined, undefined], onSelect = function onSelect() { return; }, onDeselect = function onDeselect() { return; }, color = d3Scale.scaleSequential(colorScale__namespace.interpolateGreens).domain([undefined, undefined]), stroke = "#00000011", selStroke = "#FF6600", strokewidth = 0.0, interpolated = false, showValues = false, valueFormatter = function valueFormatter(v) { return v; }, valueFontSize = function valueFontSize(h) { return h * 0.2 + "px"; }, parent_el = "body", selStrokewidth = 0.5; function chart(g) { g.each(function (data) { var selx = selected[0]; var sely = selected[1]; if (color.domain().some(isNaN)) { color.domain(extent(data, function (d) { return d.value; })); } var draw = d3Selection.select(this).call(pitch).select(".below"); var join = draw.selectAll("rect.cell") // these .data(data); var enterSel = join.enter().append("g").attr("class", "cell").attr("id", function (d) { return "cell(".concat(d.i, ",").concat(d.j, ")"); }); enterSel.append("rect").attr("x", function (d) { return d.x; }).attr("y", function (d) { return d.y; }).attr("width", function (d) { return d.width; }).attr("height", function (d) { return d.height; }).attr("data", function (d) { return d.value; }).style("stroke", function (d) { return selx === d.x && sely === d.y ? selStroke : stroke; }).style("stroke-width", function (d) { return selx === d.x && sely === d.y ? selStrokewidth : strokewidth; }).style("fill", function (d) { return interpolated ? "transparent" : color(+d.value); }).style("cursor", enableInteraction ? "crosshair" : "default").on("mouseover", function (_, d) { if (enableInteraction) d3Selection.select(this).style("stroke", selStroke).style("stroke-width", selStrokewidth); onSelect(d.x, d.y, d.value); }).on("mouseout", function (_, d) { if (enableInteraction) d3Selection.select(this).style("stroke", stroke).style("stroke-width", strokewidth); onDeselect(d.x, d.y, d.value); }); join.merge(enterSel).select("rect") //.transition() .attr("x", function (d) { return d.x; }).attr("y", function (d) { return d.y; }).attr("width", function (d) { return d.width; }).attr("height", function (d) { return d.height; }).attr("data", function (d) { return d.value; }).style("fill", function (d) { return interpolated ? "transparent" : color(+d.value); }); join.exit().select("rect") //.transition() .attr("width", 0).attr("height", 0).remove(); if (showValues) { enterSel.append("text").attr("x", function (d) { return d.x + d.width / 2; }).attr("y", function (d) { return d.y + d.height / 2; }).attr("dy", ".35em").attr("text-anchor", "middle").text(function (d) { return valueFormatter(d.value); }).style("fill", "white").style("mix-blend-mode", "difference").style("font-size", function (d) { return valueFontSize(d.height, d.width, d.value); }).style("pointer-events", "none"); join.merge(enterSel).select("text").attr("x", function (d) { return d.x + d.width / 2; }).attr("y", function (d) { return d.y + d.height / 2; }).text(function (d) { return valueFormatter(d.value); }); join.exit().select("text") //.transition() .style("font-size", 0).remove(); } if (interpolated) { var gx = max(data, function (d) { return d.i; }) + 1; var gy = max(data, function (d) { return d.j; }) + 1; var grid = []; for (var x = 0; x < gx; x++) { grid.push([]); for (var y = 0; y < gy; y++) { grid[x].push(data.find(function (d) { return d.i === x && d.j === y; }).value); } } var canvas = d3Selection.select(parent_el).style("position", "relative").append("canvas").style("z-index", -1).style("position", "absolute").style("pointer-events", "none"); var bbox = position(draw, d3Selection.select(parent_el)); var n = parseInt(bbox.height); var m = parseInt(bbox.width); var scaleX = (pitch.clip()[1][0] - pitch.clip()[0][0]) / 105; var scaleY = (pitch.clip()[1][1] - pitch.clip()[0][1]) / 68; if (pitch.rotate()) { scaleY = (pitch.clip()[1][1] - pitch.clip()[0][1]) / 105; scaleX = (pitch.clip()[1][0] - pitch.clip()[0][0]) / 68; } canvas.attr("width", m * scaleX).attr("height", n * scaleY).style("left", bbox.left + "px").style("top", bbox.top + "px"); var gridInterpolator = bicubicInterpolateExports.createGridInterpolator(grid, { extrapolate: true, scaleX: pitch.rotate() ? gx / n : gx / m, scaleY: pitch.rotate() ? gy / m : gy / n, translateX: -0.5, translateY: -0.5 }); var context = canvas.node().getContext("2d"), image = context.createImageData(m, n); var l = 0; for (var j = 0; j < n; ++j) { for (var i = 0; i < m; ++i) { var v; if (pitch.rotate()) v = gridInterpolator(n - j, i);else v = gridInterpolator(i, j); var c = d3Color.rgb(color(v || 0)); image.data[l + 0] = c.r; image.data[l + 1] = c.g; image.data[l + 2] = c.b; image.data[l + 3] = 255; l += 4; } } context.putImageData(image, 0, 0); } }); } chart.colorScale = function (_) { if (!arguments.length) return color; color = _; return chart; }; chart.selected = function (_) { if (!arguments.length) return selected; selected = _; return chart; }; chart.enableInteraction = function (_) { if (!arguments.length) return enableInteraction; enableInteraction = Boolean(_); return chart; }; chart.showValues = function (_) { if (!arguments.length) return showValues; showValues = Boolean(_); return chart; }; chart.valueFormatter = function (_) { if (!arguments.length) return valueFormatter; valueFormatter = _; return chart; }; chart.valueFontSize = function (_) { if (!arguments.length) return valueFontSize; valueFontSize = _; return chart; }; chart.interpolate = function (_) { if (!arguments.length) return interpolated; interpolated = Boolean(_); return chart; }; chart.parent_el = function (_) { if (!arguments.length) return parent_el; parent_el = _; return chart; }; chart.onSelect = function (f) { onSelect = f; return chart; }; chart.onDeselect = function (f) { onDeselect = f; return chart; }; return chart; } function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } function _arrayWithHoles(r) { if (Array.isArray(r)) return r; } function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e) { t && (r = t); var n = 0, F = function () {}; return { s: F, n: function () { return n >= r.length ? { done: !0 } : { done: !1, value: r[n++] }; }, e: function (r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function () { t = t.call(r); }, n: function () { var r = t.next(); return a = r.done, r; }, e: function (r) { u = !0, o = r; }, f: function () { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; } function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) ; else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } var noop = {value: () => {}}; function dispatch() { for (var i = 0, n = arguments.length, _ = {}, t; i < n; ++i) { if (!(t = arguments[i] + "") || (t in _) || /[\s.]/.test(t)) throw new Error("illegal type: " + t); _[t] = []; } return new Dispatch(_); } function Dispatch(_) { this._ = _; } function parseTypenames(typenames, types) { return typenames.trim().split(/^|\s+/).map(function(t) { var name = "", i = t.indexOf("."); if (i >= 0) name = t.slice(i + 1), t = t.slice(0, i); if (t && !types.hasOwnProperty(t)) throw new Error("unknown type: " + t); return {type: t, name: name}; }); } Dispatch.prototype = dispatch.prototype = { constructor: Dispatch, on: function(typename, callback) { var _ = this._, T = parseTypenames(typename + "", _), t, i = -1, n = T.length; // If no callback was specified, return the callback of the given type and name. if (arguments.length < 2) { while (++i < n) if ((t = (typename = T[i]).type) && (t = get(_[t], typename.name))) return t; return; } // If a type was specified, set the callback for the given type and name. // Otherwise, if a null callback was specified, remove callbacks of the given name. if (callback != null && typeof callback !== "function") throw new Error("invalid callback: " + callback); while (++i < n) { if (t = (typename = T[i]).type) _[t] = set(_[t], typename.name, callback); else if (callback == null) for (t in _) _[t] = set(_[t], typename.name, null); } return this; }, copy: function() { var copy = {}, _ = this._; for (var t in _) copy[t] = _[t].slice(); return new Dispatch(copy); }, call: function(type, that) { if ((n = arguments.length - 2) > 0) for (var args = new Array(n), i = 0, n, t; i < n; ++i) args[i] = arguments[i + 2]; if (!this._.hasOwnProperty(type)) throw new Error("unknown type: " + type); for (t = this._[type], i = 0, n = t.length; i < n; ++i) t[i].value.apply(that, args); }, apply: function(type, that, args) { if (!this._.hasOwnProperty(type)) throw new Error("unknown type: " + type); for (var t = this._[type], i = 0, n = t.length; i < n; ++i) t[i].value.apply(that, args); } }; function get(type, name) { for (var i = 0, n = type.length, c; i < n; ++i) { if ((c = type[i]).name === name) { return c.value; } } } function set(type, name, callback) { for (var i = 0, n = type.length; i < n; ++i) { if (type[i].name === name) { type[i] = noop, type = type.slice(0, i).concat(type.slice(i + 1)); break; } } if (callback != null) type.push({name: name, value: callback}); return type; } // These are typically used in conjunction with noevent to ensure that we can // preventDefault on the event. const nonpassive = {passive: false}; const nonpassivecapture = {capture: true, passive: false}; function nopropagation(event) { event.stopImmediatePropagation(); } function noevent(event) { event.preventDefault(); event.stopImmediatePropagation(); } function nodrag(view) { var root = view.document.documentElement, selection = d3Selection.select(view).on("dragstart.drag", noevent, nonpassivecapture); if ("onselectstart" in root) { selection.on("selectstart.drag", noevent, nonpassivecapture); } else { root.__noselect = root.style.MozUserSelect; root.style.MozUserSelect = "none"; } } function yesdrag(view, noclick) { var root = view.document.documentElement, selection = d3Selection.select(view).on("dragstart.drag", null); if (noclick) { selection.on("click.drag", noevent, nonpassivecapture); setTimeout(function() { selection.on("click.drag", null); }, 0); } if ("onselectstart" in root) { selection.on("selectstart.drag", null); } else { root.style.MozUserSelect = root.__noselect; delete root.__noselect; } } var constant = x => () => x; function DragEvent(type, { sourceEvent, subject, target, identifier, active, x, y, dx, dy, dispatch }) { Object.defineProperties(this, { type: {value: type, enumerable: true, configurable: true}, sourceEvent: {value: sourceEvent, enumerable: true, configurable: true}, subject: {value: subject, enumerable: true, configurable: true}, target: {value: target, enumerable: true, configurable: true}, identifier: {value: identifier, enumerable: true, configurable: true}, active: {value: active, enumerable: true, configurable: true}, x: {value: x, enumerable: true, configurable: true}, y: {value: y, enumerable: true, configurable: true}, dx: {value: dx, enumerable: true, configurable: true}, dy: {value: dy, enumerable: true, configurable: true}, _: {value: dispatch} }); } DragEvent.prototype.on = function() { var value = this._.on.apply(this._, arguments); return value === this._ ? this : value; }; // Ignore right-click, since that should open the context menu. function defaultFilter(event) { return !event.ctrlKey && !event.button; } function defaultContainer() { return this.parentNode; } function defaultSubject(event, d) { return d == null ? {x: event.x, y: event.y} : d; } function defaultTouchable() { return navigator.maxTouchPoints || ("ontouchstart" in this); } function d3Drag() { var filter = defaultFilter, container = defaultContainer, subject = defaultSubject, touchable = defaultTouchable, gestures = {}, listeners = dispatch("start", "drag", "end"), active = 0, mousedownx, mousedowny, mousemoving, touchending, clickDistance2 = 0; function drag(selection) { selection .on("mousedown.drag", mousedowned) .filter(touchable) .on("touchstart.drag", touchstarted) .on("touchmove.drag", touchmoved, nonpassive) .on("touchend.drag touchcancel.drag", touchended) .style("touch-action", "none") .style("-webkit-tap-highlight-color", "rgba(0,0,0,0)"); } function mousedowned(event, d) { if (touchending || !filter.call(this, event, d)) return; var gesture = beforestart(this, container.call(this, event, d), event, d, "mouse"); if (!gesture) return; d3Selection.select(event.view) .on("mousemove.drag", mousemoved, nonpassivecapture) .on("mouseup.drag", mouseupped, nonpassivecapture); nodrag(event.view); nopropagation(event); mousemoving = false; mousedownx = event.clientX; mousedowny = event.clientY; gesture("start", event); } function mousemoved(event) { noevent(event); if (!mousemoving) { var dx = event.clientX - mousedownx, dy = event.clientY - mousedowny; mousemoving = dx * dx + dy * dy > clickDistance2; } gestures.mouse("drag", event); } function mouseupped(event) { d3Selection.select(event.view).on("mousemove.drag mouseup.drag", null); yesdrag(event.view, mousemoving); noevent(event); gestures.mouse("end", event); } function touchstarted(event, d) { if (!filter.call(this, event, d)) return; var touches = event.changedTouches, c = container.call(this, event, d), n = touches.length, i, gesture; for (i = 0; i < n; ++i) { if (gesture = beforestart(this, c, event, d, touches[i].identifier, touches[i])) { nopropagation(event); gesture("start", event, touches[i]); } } } function touchmoved(event) { var touches = event.changedTouches, n = touches.length, i, gesture; for (i = 0; i < n; ++i) { if (gesture = gestures[touches[i].identifier]) { noevent(event); gesture("drag", event, touches[i]); } } } function touchended(event) { var touches = event.changedTouches, n = touches.length, i, gesture; if (touchending) clearTimeout(touchending); touchending = setTimeout(function() { touchending = null; }, 500); // Ghost clicks are delayed! for (i = 0; i < n; ++i) { if (gesture = gestures[touches[i].identifier]) { nopropagation(event); gesture("end", event, touches[i]); } } } function beforestart(that, container, event, d, identifier, touch) { var dispatch = listeners.copy(), p = d3Selection.pointer(touch || event, container), dx, dy, s; if ((s = subject.call(that, new DragEvent("beforestart", { sourceEvent: event, target: drag, identifier, active, x: p[0], y: p[1], dx: 0, dy: 0, dispatch }), d)) == null) return; dx = s.x - p[0] || 0; dy = s.y - p[1] || 0; return function gesture(type, event, touch) { var p0 = p, n; switch (type) { case "start": gestures[identifier] = gesture, n = active++; break; case "end": delete gestures[identifier], --active; // falls through case "drag": p = d3Selection.pointer(touch || event, container), n = active; break; } dispatch.call( type, that, new DragEvent(type, { sourceEvent: event, subject: s, target: drag, identifier, active: n, x: p[0] + dx, y: p[1] + dy, dx: p[0] - p0[0], dy: p[1] - p0[1], dispatch }), d ); }; } drag.filter = function(_) { return arguments.length ? (filter = typeof _ === "function" ? _ : constant(!!_), drag) : filter; }; drag.container = function(_) { return arguments.length ? (container = typeof _ === "function" ? _ : constant(_), drag) : container; }; drag.subject = function(_) { return arguments.length ? (subject = typeof _ === "function" ? _ : constant(_), drag) : subject; }; drag.touchable = function(_) { return arguments.length ? (touchable = typeof _ === "function" ? _ : constant(!!_), drag) : touchable; }; drag.on = function() { var value = listeners.on.apply(listeners, arguments); return value === listeners ? drag : value; }; drag.clickDistance = function(_) { return arguments.length ? (clickDistance2 = (_ = +_) * _, drag) : Math.sqrt(clickDistance2); }; return drag; } /** * actions - draws a sequence of SPADL actions * on a pitch. * */ function actions (pitch) { var scale = 4; var showTooltip = false; var draggable = false; var teamColors = {}; var onUpdate = function onUpdate() { return undefined; }; /** * chart - constructor function. * Appends the plot to the g selection. * * @param g - d3 selection of elements to which the plot will be appended. */ function chart(g) { g.each(function (data) { // Create the soccer pitch var actionsLayer = d3Selection.select(this).call(pitch).select(".above"); // Create an arrow symbol actionsLayer.append("svg:defs").append("svg:marker").attr("id", "triangle").attr("refX", 11).attr("refY", 6).attr("dy", 6).attr("markerWidth", 12).attr("markerHeight", 12).attr("orient", "auto").append("path").attr("d", "M 0 0 12 6 0 12 3 6"); var dragHandler = d3Drag().on("start", function () { d3Selection.select(this).classed("active", true); }).on("drag", function (d) { var _d3Pointer = d3Selection.pointer(actionsLayer.node()), _d3Pointer2 = _slicedToArray(_d3Pointer, 2), x = _d3Pointer2[0], y = _d3Pointer2[1]; var marker = d3Selection.select(this).attr("marker"); d3Selection.select(this).attr("transform", "translate(" + x + "," + y + ")"); var action = d3Selection.select("#action-".concat(d.action_id)); if (marker === "marker-start") { action.select("line").attr("x1", x).attr("y1", y); var prev_action = d3Selection.select("#action-".concat(d.action_id - 1)); prev_action.select("line").attr("x2", x).attr("y2", y); } else { action.select("line").attr("x2", x).attr("y2", y); } }).on("end", function (d) { var _d3Pointer3 = d3Selection.pointer(actionsLayer.node()), _d3Pointer4 = _slicedToArray(_d3Pointer3, 2), x = _d3Pointer4[0], y = _d3Pointer4[1]; var marker = d3Selection.select(this).attr("marker"); d3Selection.select(this).classed("active", false); var newData = data.slice(); if (marker === "marker-start") { newData.find(function (a) { return a.action_id === d.action_id; })["start_x"] = x; newData.find(function (a) { return a.action_id === d.action_id; })["start_y"] = 68 - y; var prevAction = newData.find(function (a) { return a.action_id === d.action_id - 1; }); if (prevAction) { prevAction["end_x"] = x; prevAction["end_y"] = 68 - y; } } else { newData.find(function (a) { return a.action_id === d.action_id; })["end_x"] = x; newData.find(function (a) { return a.action_id === d.action_id; })["end_y"] = 68 - y; } onUpdate(newData); }); // Select a unique color for each team var defaultColors = ["#FDB913", "#87CEEB"]; var _iterator = _createForOfIteratorHelper(data), _step; try { for (_iterator.s(); !(_step = _iterator.n()).done;) { var action = _step.value; if (action.team_id in teamColors === false) { if (Object.keys(teamColors).length >= 2) throw "You specified ids of teams that do not occur in the data!";else teamColors[action.team_id] = defaultColors[Object.keys(teamColors).length]; } } // Draw the actions } catch (err) { _iterator.e(err); } finally { _iterator.f(); } var update = actionsLayer.selectAll("g.action").data(data); update.each(function (d, i) { d3Selection.select(this).selectAll("*").remove(); var action = d3Selection.select(this).attr("class", "action").attr("id", "action-".concat(d.action_id)).call(actiontypePlotFn[d.type_id]["plot"], d, i + 1, teamColors[d.team_id], scale); action.selectAll("g.symbol").on("mouseover", function (e, d) { return showTooltip && showTooltip.show(e, d); }).on("mouseout", function () { return showTooltip && showTooltip.hide(); }); }); update.enter().append("g").attr("class", "action").each(function (d, i) { var action = d3Selection.select(this).attr("class", "action").attr("id", "action-".concat(d.action_id)).call(actiontypePlotFn[d.type_id]["plot"], d, i + 1, teamColors[d.team_id], scale); action.selectAll("g.symbol").on("mouseover", function (e, d) { return showTooltip && showTooltip.show(e, d); }).on("mouseout", function () { return showTooltip && showTooltip.hide(); }); }); var last_action = actionsLayer.selectAll(".action:last-of-type"); var mend = last_action.selectAll("g.marker-end").data(function (d) { return [d]; }).enter().append("g").attr("class", "marker marker-end").attr("marker", "marker-end").attr("transform", function (d) { return "translate(" + d.end_x + "," + (68 - d.end_y) + ")"; }); mend.append("circle").attr("stroke-width", 0).attr("fill", "#000").attr("opacity", 0).attr("r", 2); update.exit().remove(); if (draggable) dragHandler(actionsLayer.selectAll("g.marker")); }); } chart.showTooltip = function (_) { if (!arguments.length) return showTooltip; showTooltip = _; return chart; }; chart.scale = function (_) { if (!arguments.length) return scale; scale = _; return chart; }; chart.draggable = function (_) { if (!arguments.length) return draggable; draggable = Boolean(_); return chart; }; chart.teamColors = function (_) { if (!arguments.length) return teamColors; teamColors = _; return chart; }; chart.onUpdate = function (f) { if (!arguments.length) return onUpdate; onUpdate = f; return chart; }; return chart; } var actiontypePlotFn = [{ label: "Pass", plot: plotPass, symbol: symbolPass }, { label: "Cross", plot: plotPass, symbol: symbolPass }, { label: "Throw in", plot: plotPass, symbol: symbolPass }, { label: "Freekick (cross)", plot: plotPass, symbol: symbolPass }, { label: "Freekick (short)", plot: plotPass, symbol: symbolPass }, { label: "Corner (cross)", plot: plotPass, symbol: symbolPass }, { label: "Corner (short)", plot: plotPass, symbol: symbolPass }, { label: "Dribble", plot: plotDribble