Skip to contents
On this page

sdv_pitch_coords() converts soccer event coordinates from any major provider to one frame: meters from the center spot of a regulation 105 x 68 m pitch, attacking toward +x, with +y on the attacker’s left. sdv_surface("soccer") draws that pitch.

ESPN: the Premier League’s final day of 2025-26

ESPN’s play-by-play (the same feed sportsdataverse-py’s espn_soccer_game_plays() reads) records each shot’s distance from the goal it attacks as a fraction of half the pitch. Manchester City (ESPN id 382) against Aston Villa (362); the away team’s shots are flipped so the two sides attack opposite ends.

url <- paste0(
  "https://sports.core.api.espn.com/v2/sports/soccer/leagues/eng.1/events/740970/",
  "competitions/740970/plays?limit=1000"
)
items <- jsonlite::fromJSON(url)$items
plays <- data.frame(
  type = items$type$text,
  field_position_x = items$fieldPositionX,
  field_position_y = items$fieldPositionY,
  team = sub(".*/teams/([0-9]+).*", "\\1", items$team$`$ref`),
  goal = items$scoringPlay
)
shots <- plays[grepl("shot|goal", plays$type, ignore.case = TRUE) & plays$type != "Assists Shot", ]
shots <- sdv_pitch_coords(transform(shots, away = team != "382"), "espn", flip = "away")
shots <- shots[!is.na(shots$pitch_x), ]

sdv_surface("soccer") +
  geom_point(aes(pitch_x, pitch_y), data = shots[!shots$goal, ], shape = 21, size = 3, fill = NA, color = "white") +
  geom_sdv_logos(aes(pitch_x, pitch_y, team = team), data = shots[shots$goal, ], league = "soccer", width = 0.05) +
  labs(title = "Manchester City v Aston Villa: every shot (logos: goals)", caption = "Data: ESPN")

StatsBomb: the 2018 World Cup final

The same function reads StatsBomb’s 120 x 80 frame. StatsBomb open data is free for non-commercial use.

events <- jsonlite::fromJSON(
  "https://raw.githubusercontent.com/statsbomb/open-data/master/data/events/8658.json",
  simplifyDataFrame = FALSE
)
shots_sb <- do.call(rbind, lapply(Filter(function(e) e$type$name == "Shot", events), function(e) {
  data.frame(team = e$team$name, x = e$location[[1]], y = e$location[[2]], goal = e$shot$outcome$name == "Goal")
}))
shots_sb <- sdv_pitch_coords(transform(shots_sb, croatia = team == "Croatia"), "statsbomb", flip = "croatia")

sdv_surface("soccer") +
  geom_point(aes(pitch_x, pitch_y, color = team, size = goal), data = shots_sb, alpha = 0.8) +
  scale_color_manual(values = c(France = "#1f3f8f", Croatia = "#d71920")) +
  scale_size_manual(values = c(`FALSE` = 2, `TRUE` = 5), guide = "none") +
  labs(title = "France v Croatia, 2018: every shot (large: goals)", caption = "Data: StatsBomb open data")

In the provider’s own frame, with ggsoccer

To keep a provider’s frame instead, ggsoccer draws its pitch; StatsBomb’s y runs top to bottom, hence the reversed scale.

raw_sb <- shots_sb[c("team", "x", "y", "goal")]
ggplot(raw_sb, aes(x, y, color = team)) +
  ggsoccer::annotate_pitch(dimensions = ggsoccer::pitch_statsbomb) +
  geom_point() +
  scale_y_reverse() +
  ggsoccer::theme_pitch()
#> Scale for y is already present.
#> Adding another scale for y, which will replace the existing scale.

sdv_team_colors("soccer", c("359", "382", "18418"))
#>       359       382     18418 
#> "#e20520" "#99c5ea" "#9d2235"
sdv_surface("soccer", "359", center_logo = TRUE)

Make it interactive

The ESPN shot map from the first section, with a tooltip on every shot. The misses become geom_point_interactive() from ggiraph, the goals geom_sdv_logos_interactive(), which draws each logo and an invisible hover target over it, and ggiraph::girafe() renders the pitch as an interactive SVG. Hover a shot for the team, ESPN’s description of it and its distance from the goal it attacked, worked out from the same coordinates the chart plots.

club_index <- sdv_teams("soccer")
shots$club <- club_index$name[match(shots$team, club_index$team_id)]
shots$meters <- sqrt((ifelse(shots$away, -52.5, 52.5) - shots$pitch_x)^2 + shots$pitch_y^2)
shots$tip <- sprintf("<b>%s</b><br>%s<br>%.0f m from goal", shots$club, shots$type, shots$meters)

p_shots <- sdv_surface("soccer") +
  ggiraph::geom_point_interactive(
    aes(pitch_x, pitch_y, tooltip = tip),
    data = shots[!shots$goal, ], shape = 21, size = 3, fill = scales::alpha("white", 0.01), color = "white"
  ) +
  geom_sdv_logos_interactive(
    aes(pitch_x, pitch_y, team = team, tooltip = tip),
    data = shots[shots$goal, ], league = "soccer", width = 0.05
  ) +
  labs(title = "Manchester City v Aston Villa: every shot (logos: goals)", subtitle = "Hover a shot", caption = "Data: ESPN")

ggiraph::girafe(
  ggobj = p_shots, width_svg = 9, height_svg = 6,
  options = list(ggiraph::opts_tooltip(css = "background:white;color:black;padding:6px;border-radius:6px;"))
)

The circles are filled with white at 1% opacity: a ring with no fill catches the pointer only on its outline. Interactive charts covers ggiraph, plotly and leaflet.

Citation

To cite the sdvplotR R package in publications, use:

BibTeX Citation

@misc{gilani_carl_lee_kay_weatherman_sdvplotR,
  author = {Gilani, Saiem and Carl, Sebastian and Lee, Jared and Kay, Camden and Weatherman, Andrew},
  title = {sdvplotR: The SportsDataverse's R Package for Multi-League Sports Plotting.},
  url = {https://sdvplotR.sportsdataverse.org},
  year = {2026}
}