Soccer Pitch Event Map — Apache ECharts

A soccer pitch event map positions match events (passes, shots, tackles, interceptions) as markers on an accurately scaled football pitch diagram. The pitch is drawn with standard markings including penalty areas, center circle, goal areas, and halfway line. Each event type uses distinct markers and colors, with directional arrows for passes and shots. This visualization is essential for tactical match analysis, scouting, and coaching in football analytics.

Soccer Pitch Event Map rendered with Apache ECharts

Renders

JavaScript source (Apache ECharts)

// anyplot.ai
// scatter-pitch-events: Soccer Pitch Event Map
// Library: echarts 5.5.1 | JavaScript 22.22.3
// Quality: 90/100 | Created: 2026-06-21

const t = window.ANYPLOT_TOKENS;
const THEME = window.ANYPLOT_THEME;

// Seeded LCG for reproducible data
let seed = 42;
function rng() {
  seed = (seed * 1664525 + 1013904223) >>> 0;
  return seed / 4294967296;
}

// Pitch constants (FIFA standard, metres)
const PW = 105, PH = 68;
const PITCH_GREEN = THEME === 'dark' ? '#2C5A27' : '#3D7A35';
const PITCH_LINE  = 'rgba(255,255,255,0.88)';

// Geometry helpers
function circlePts(cx, cy, r, n = 64) {
  const pts = [];
  for (let i = 0; i <= n; i++) {
    const a = (i / n) * 2 * Math.PI;
    pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
  }
  return pts;
}

function arcPts(cx, cy, r, a0, a1, n = 32) {
  const pts = [];
  for (let i = 0; i <= n; i++) {
    const a = a0 + (i / n) * (a1 - a0);
    pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
  }
  return pts;
}

function mkLine(data) {
  return {
    type: 'line',
    data,
    symbol: 'none',
    lineStyle: { color: PITCH_LINE, width: 2.5 },
    z: 2,
    silent: true,
    legendHoverLink: false,
    emphasis: { disabled: true },
  };
}

// Pitch marking bounds (FIFA)
const PA_Y1   = (PH - 40.32) / 2;         // 13.84 — penalty area bottom
const PA_Y2   = PH - PA_Y1;               // 54.16 — penalty area top
const GA_Y1   = (PH - 18.32) / 2;         // 24.84 — goal area bottom
const GA_Y2   = PH - GA_Y1;               // 43.16 — goal area top
const PARC    = Math.acos(5.5 / 9.15);    // penalty arc half-angle (~0.926 rad)

const pitchSeries = [
  mkLine([[0,0],[PW,0],[PW,PH],[0,PH],[0,0]]),                  // outer boundary
  mkLine([[PW/2,0],[PW/2,PH]]),                                  // halfway line
  mkLine(circlePts(PW/2, PH/2, 9.15)),                          // center circle
  mkLine(circlePts(PW/2, PH/2, 0.5, 20)),                       // center spot
  mkLine([[0,PA_Y1],[16.5,PA_Y1],[16.5,PA_Y2],[0,PA_Y2]]),      // left penalty area
  mkLine([[0,GA_Y1],[5.5,GA_Y1],[5.5,GA_Y2],[0,GA_Y2]]),        // left goal area
  mkLine(circlePts(11, PH/2, 0.5, 20)),                         // left penalty spot
  mkLine(arcPts(11, PH/2, 9.15, -PARC, PARC)),                  // left penalty arc
  mkLine([[PW,PA_Y1],[88.5,PA_Y1],[88.5,PA_Y2],[PW,PA_Y2]]),    // right penalty area
  mkLine([[PW,GA_Y1],[99.5,GA_Y1],[99.5,GA_Y2],[PW,GA_Y2]]),    // right goal area
  mkLine(circlePts(94, PH/2, 0.5, 20)),                         // right penalty spot
  mkLine(arcPts(94, PH/2, 9.15, Math.PI-PARC, Math.PI+PARC)),  // right penalty arc
  mkLine(arcPts(0,  0,  1, 0,           Math.PI/2)),            // corner arcs
  mkLine(arcPts(PW, 0,  1, Math.PI/2,   Math.PI)),
  mkLine(arcPts(PW, PH, 1, Math.PI,     3*Math.PI/2)),
  mkLine(arcPts(0,  PH, 1, 3*Math.PI/2, 2*Math.PI)),
  mkLine([[-2.44,30.34],[0,30.34]]),                             // left goal
  mkLine([[-2.44,37.66],[0,37.66]]),
  mkLine([[-2.44,30.34],[-2.44,37.66]]),
  mkLine([[PW,30.34],[PW+2.44,30.34]]),                          // right goal
  mkLine([[PW,37.66],[PW+2.44,37.66]]),
  mkLine([[PW+2.44,30.34],[PW+2.44,37.66]]),
];

// Generate event data
const passes = [], shots = [], tackles = [], intercepts = [];

for (let i = 0; i < 80; i++) {
  const x1 = rng() * PW, y1 = rng() * PH;
  const x2 = Math.max(1, Math.min(PW-1, x1 + (rng()-0.25)*20));
  const y2 = Math.max(1, Math.min(PH-1, y1 + (rng()-0.5)*14));
  passes.push({ x1, y1, x2, y2, ok: rng() > 0.28 });
}
for (let i = 0; i < 22; i++) {
  shots.push({ x: 62 + rng()*40, y: 14 + rng()*40, ok: rng() > 0.68 });
}
for (let i = 0; i < 35; i++) {
  tackles.push({ x: rng()*PW, y: rng()*PH, ok: rng() > 0.38 });
}
for (let i = 0; i < 25; i++) {
  intercepts.push({ x: rng()*PW*0.65, y: rng()*PH });
}

// Arrow data for lines series (pass trajectories, shot vectors to goal)
const passArrows = passes.map(p => ({
  coords: [[p.x1,p.y1],[p.x2,p.y2]],
  lineStyle: { opacity: p.ok ? 0.55 : 0.18 },
}));
const shotArrows = shots.map(s => ({
  coords: [[s.x,s.y],[PW+0.5, PH/2]],
  lineStyle: { opacity: s.ok ? 0.55 : 0.18 },
}));

const eventSeries = [
  // Pass arrows
  {
    type: 'lines', coordinateSystem: 'cartesian2d',
    data: passArrows,
    symbol: ['none','arrow'], symbolSize: 6,
    effect: { show: false },
    lineStyle: { color: t.palette[0], width: 1.6 },
    z: 4, silent: true, legendHoverLink: false,
  },
  // Shot trajectories (dashed arrow to goal)
  {
    type: 'lines', coordinateSystem: 'cartesian2d',
    data: shotArrows,
    symbol: ['none','arrow'], symbolSize: 7,
    effect: { show: false },
    lineStyle: { color: t.palette[1], width: 1.6, type: 'dashed' },
    z: 4, silent: true, legendHoverLink: false,
  },
  // Pass markers — circles, palette[0] = #009E73 (first series)
  {
    type: 'scatter', name: 'Pass',
    data: passes.map(p => ({ value: [p.x1,p.y1], itemStyle: { opacity: p.ok ? 0.88 : 0.38 } })),
    symbol: 'circle', symbolSize: 10,
    itemStyle: { color: t.palette[0], borderColor: 'rgba(255,255,255,0.5)', borderWidth: 1 },
    z: 6,
  },
  // Shot markers — diamonds
  {
    type: 'scatter', name: 'Shot',
    data: shots.map(s => ({ value: [s.x,s.y], itemStyle: { opacity: s.ok ? 1.0 : 0.4 } })),
    symbol: 'diamond', symbolSize: 16,
    itemStyle: { color: t.palette[1], borderColor: 'rgba(255,255,255,0.6)', borderWidth: 1.5 },
    z: 7,
  },
  // Tackle markers — triangles
  {
    type: 'scatter', name: 'Tackle',
    data: tackles.map(s => ({ value: [s.x,s.y], itemStyle: { opacity: s.ok ? 0.88 : 0.38 } })),
    symbol: 'triangle', symbolSize: 14,
    itemStyle: { color: t.palette[2], borderColor: 'rgba(255,255,255,0.5)', borderWidth: 1 },
    z: 6,
  },
  // Interception markers — squares
  {
    type: 'scatter', name: 'Interception',
    data: intercepts.map(s => ({ value: [s.x,s.y], itemStyle: { opacity: 0.88 } })),
    symbol: 'rect', symbolSize: 12,
    itemStyle: { color: t.palette[3], borderColor: 'rgba(255,255,255,0.5)', borderWidth: 1 },
    z: 6,
  },
];

// Pitch surface background via markArea on empty scatter
const bgSeries = {
  type: 'scatter',
  data: [],
  z: 0, silent: true, legendHoverLink: false,
  markArea: {
    silent: true, z: 0,
    itemStyle: { color: PITCH_GREEN, opacity: 1, borderWidth: 0 },
    emphasis: { disabled: true },
    data: [[{ coord: [0,0] }, { coord: [PW,PH] }]],
  },
};

// Aspect ratio: X range (-4 to 109) = 113 units, Y range (-1.5 to 69.5) = 71 units
// CSS mount 1600×900; grid top/bottom = 65 each → grid H = 770 px
// Grid W = 770 × (113/71) = 1226 → left/right margin = (1600-1226)/2 = 187
const chart = echarts.init(document.getElementById('container'));

chart.setOption({
  animation: false,
  color: t.palette,
  backgroundColor: t.pageBg,

  title: {
    text: 'scatter-pitch-events · javascript · echarts · anyplot.ai',
    subtext: 'Opacity = outcome (bright: successful · faded: unsuccessful)  ·  Arrows show event direction',
    left: 'center',
    top: 14,
    textStyle: { color: t.ink, fontSize: 22, fontWeight: 'bold' },
    subtextStyle: { color: t.inkSoft, fontSize: 13 },
  },

  legend: {
    data: ['Pass', 'Shot', 'Tackle', 'Interception'],
    bottom: 20,
    textStyle: { color: t.inkSoft, fontSize: 14 },
    itemWidth: 18, itemHeight: 14,
  },

  tooltip: {
    trigger: 'item',
    formatter: p => {
      if (!['Pass','Shot','Tackle','Interception'].includes(p.seriesName)) return '';
      return `${p.seriesName}<br/>x: ${p.value[0].toFixed(1)}m, y: ${p.value[1].toFixed(1)}m`;
    },
  },

  grid: { left: 187, right: 187, top: 65, bottom: 65 },

  xAxis: {
    type: 'value', min: -4, max: 109,
    show: false, splitLine: { show: false },
  },
  yAxis: {
    type: 'value', min: -1.5, max: 69.5,
    show: false, splitLine: { show: false },
  },

  series: [bgSeries, ...pitchSeries, ...eventSeries],
});

Part of Soccer Pitch Event Map on anyplot.ai.

Other implementations