Educacional Artigos Livros Serviços Academy
Academy / Rede de Passes

Redes de Passes no Futebol — Parte II: O Código em Detalhe

Na primeira parte deste artigo, cobrimos a arquitetura geral, a paleta cromática, a extração do grafo e o motor de plotagem base_plot_tactics(). Nesta segunda parte, percorremos o restante do código linha a linha: os utilitários auxiliares, o tema completo, o processamento por zona, o painel comparativo com patchwork, a leitura da base, e o bloco de saídas que gera os arquivos finais. É aqui que o pipeline se revela completo — do read_csv() ao ggsave().


12. Utilitários Auxiliares

Antes de qualquer plot, o script define pequenos utilitários que evitam repetição e centralizam regras de formatação.

12.1 Abreviação de nomes

abreviar_nome <- function(nome) {
  if (is.na(nome)) return("")
  partes <- str_split(nome, " ")[[1]]
  if (length(partes) > 1) {
    return(paste0(substr(partes[1], 1, 1), ". ", partes[length(partes)]))
  }
  nome
}

A regra transforma "Rodrigo Garro" em "R. Garro" e "Yuri Alberto" em "Y. Alberto". O detalhe importante: usa a primeira letra do primeiro nome + o último sobrenome. Isso evita ambiguidades em bases brasileiras onde muitos jogadores têm nomes compostos (ex.: “Matheus Santos” → “M. Santos” diferencia de “Matheus Pereira” → “M. Pereira”). É uma decisão de design que prioriza o sobrenome como identificador, o que é culturalmente correto no futebol.

12.2 Limiares de posição — detalhamento

Na Parte I mencionamos determinar_posicao(), mas vale detalhar os limiares exatos:

linha <- case_when(x <= 12 ~ "gk", x <= 38 ~ "def", x <= 62 ~ "med", TRUE ~ "ata")
lado  <- case_when(y <= 30 ~ "dir", y <= 45 ~ "dir_c", y <= 55 ~ "c",
                   y <= 70 ~ "esq_c", TRUE ~ "esq")

São quatro faixas longitudinais (x) e cinco faixas laterais (y), produzindo uma matriz de até 20 combinações, mas apenas 12 siglas reais são retornadas (GK, LD, ZD, ZC, ZE, LE, MD, ME, MC, AD, AE, ATA). A simplificação faz sentido: ninguém lê “ZD_C” ou “ME_C” no meio de um nó de 3,6pt. O case_when final colapsa os casos residuais em categorias genéricas.

12.3 Quebras inteiras nas legendas

quebras_int <- function(lim) {
  b <- scales::breaks_extended(n = 4)(lim)
  b <- unique(round(b[b == round(b)]))
  if (length(b) == 0) b <- unique(round(lim))
  b
}

Essa função é mais sutil do que parece. breaks_extended() pode retornar valores decimais (ex.: 2,5 passes), o que é absurdo numa contagem. O filtro b[b == round(b)] mantém apenas inteiros, e o fallback cobre o caso extremo em que nenhum inteiro sobrevive (ex.: rede com apenas 3 e 4 passes). É um exemplo de robustez silenciosa: o código não falha em redes pequenas, apenas se adapta.

12.4 Reatribuição de funções mascaradas

select    <- dplyr::select
filter    <- dplyr::filter
lag       <- dplyr::lag
intersect <- dplyr::intersect
union     <- dplyr::union
setdiff   <- dplyr::setdiff

Parece redundante depois de library(tidyverse), mas é uma rede de segurança. Se o usuário carregar library(MASS) em outra sessão (que mascara select), ou library(stats) (que tem lag, filter, intersect), o script continua funcionando. É um padrão defensivo comum em scripts que serão compartilhados com colegas em ambientes diferentes.


13. O Tema Completo

A configuração do theme() em base_plot_tactics() merece uma análise detalhada, porque é onde a identidade visual se materializa. Além de theme_pitch() (do ggsoccer, que remove eixos e grades padrão), o código define:

13.1 Hierarquia tipográfica

plot.title = element_text(size = 24 * esc, face = "bold", family = ST$f_title,
                          hjust = 0.5, margin = margin(b = 6))
plot.subtitle = element_text(size = 14 * esc, hjust = 0.5,
                             margin = margin(b = 12))
plot.caption = element_text(size = 8.5 * esc, lineheight = 1.3,
                            hjust = 0.5, margin = margin(t = 12))

Três níveis bem marcados: 24pt bold (título), 14pt regular (subtítulo), 8,5pt (legenda de siglas). Todos com hjust = 0.5 para centralização — decisão que combina com o formato vertical 1080×1440 e reforça a leitura de “poster” do gráfico.

O family = ST$f_title no título usa a "Roboto Slab" (serifada), enquanto o restante usa a mesma família configurada em ST$f_base. Isso cria contraste tipográfico serifada vs. sem serifada, um recurso editorial clássico que diferencia o título do corpo.

13.2 Legenda horizontal em duas fileiras

legend.position    = "bottom",
legend.direction   = "horizontal",
legend.box         = "horizontal",
legend.box.just    = "center",
legend.box.spacing = unit(0.5, "cm"),
legend.spacing.x   = unit(1.2, "cm"),

A legenda tem duas entradas (tamanho dos nós e espessura das conexões), e ambas devem caber abaixo do campo sem quebrar linha. O legend.spacing.x = unit(1.2, "cm") é o detalhe que separa uma legenda amadora de uma profissional: sem ele, as duas legendas ficam grudadas e ilegíveis.

13.3 O caption como mini-glossário

caption = paste0("GK = Goleiro | Z = Zagueiro | L = Lateral | ...\nCampo Analítico")

Em vez de um rodapé genérico (“Fonte: Opta”), o script usa o caption como glossário de siglas — resolvendo o problema de leitores que não sabem o que significa “ZD” ou “AE”. A quebra \n separa o glossário da assinatura “Campo Analítico”.

13.4 Margens generosas

plot.margin = margin(22, 22, 22, 22)

22 pontos em todos os lados. Isso pode parecer exagero, mas em formato vertical 1080×1440 é o que garante respiro visual ao redor do campo, evitando que o gráfico pareça “apertado” no feed do Instagram.


14. O Parâmetro esc — Escala Global

Um dos parâmetros mais úteis do base_plot_tactics() é esc (escala), aplicado a quase todos os elementos tipográficos e de tamanho:

size = 3.2 * esc,           # números das zonas
size = 3.6 * esc,           # sigla da posição
size = 4.6 * esc,           # nome do jogador
range = c(8, 26) * esc,     # tamanho dos nós
size = 24 * esc,            # título
size = 14 * esc,            # subtítulo
size = 11 * esc,            # legenda

Com esc = 1 temos o gráfico individual padrão. Com esc = 0.75 (usado no comparativo) todos os elementos encolhem 25% simultaneamente — sem precisar reescrever o tema. É um mecanismo elegante de responsividade que mantém a proporção interna do gráfico intacta.

Sem esse parâmetro, seria necessário duplicar o código do tema para cada formato de saída. Com ele, o mesmo tema serve para 1080×1440 (individual) e 1080×1080 (comparativo lado a lado).


15. Espelhamento e Coordenadas

if (isTRUE(ST$espelhar_y)) p <- p + scale_y_reverse()
p + coord_flip(clip = "off")

Essa linha é contraintuitiva à primeira vista, mas essencial. Vamos desmontar:

  1. coord_flip() inverte os eixos, colocando o campo na horizontal (ataque da esquerda para a direita). Mas com isso, o eixo y original (que no OPTA vai de 0 = direita a 100 = esquerda do time) é mapeado para o eixo vertical invertido da imagem.
  2. Sem scale_y_reverse(), o lado direito do time (y próximo de 0) apareceria em cima na imagem, e o lado esquerdo embaixo — o inverso do que se espera numa leitura de campo.
  3. Aplicando scale_y_reverse() antes do coord_flip(), o resultado final é: lado direito do time à direita, lado esquerdo à esquerda. Exatamente como o técnico vê o campo.

O parâmetro ST$espelhar_y permite desligar essa correção caso a base já venha com coordenadas invertidas — outra decisão de design que prevê variabilidade entre fontes de dados (StatsBomb, Opta, Wyscout têm convenções diferentes).

O clip = "off" é o que permite que rótulos com geom_text_repel() “saiam” do painel sem serem cortados — fundamental quando o nome do jogador é longo e precisa se afastar do nó.


16. Processamento por Zona

processar_rede_por_zona <- function(df, zona = c("baixo", "medio", "alto"),
                                    jogador_foco = NULL, titulares = NULL) {
  zona <- match.arg(zona)
  limites <- switch(zona,
                    "baixo" = c(0, 33.33),
                    "medio" = c(33.33, 66.66),
                    "alto"  = c(66.66, 100))
  df_zona <- df %>% filter(x >= limites[1], x <= limites[2])
  processar_rede_logica(df_zona, "Geral", jogador_foco, titulares)
}

A função atua como wrapper de processar_rede_logica(): filtra os eventos por faixa de x e chama o mesmo motor de extração. Isso é possível porque a posição dos nós é calculada dentro do subconjunto filtrado — ou seja, no bloco baixo, o “ponto médio” de um zagueiro é a média dos passes que ele deu no bloco baixo, não a média da partida inteira.

Essa é uma decisão analítica importante: cada bloco tem sua própria geometria, o que faz sentido tático. Um lateral no bloco alto está posicionado onde ele efetivamente atuou naquele terço, não onde ele fica em média na temporada.

O match.arg() no início é uma proteção contra erros de digitação: se alguém chamar processar_rede_por_zona(df, "alto ") (com espaço), a função falha com mensagem clara em vez de retornar silenciosamente NULL.


17. O Painel Comparativo com patchwork

O comparativo 1º × 2º tempo é o trecho mais elaborado do script. Vamos percorrer suas camadas.

17.1 Extração das duas metades

col_tempo <- if ("period" %in% names(df_time)) "period" else "tempo"
d1 <- processar_rede_logica(df_time[df_time[[col_tempo]] == "FirstHalf", ], ...)
d2 <- processar_rede_logica(df_time[df_time[[col_tempo]] == "SecondHalf", ], ...)
ds <- purrr::compact(list(d1, d2))

O col_tempo é uma detecção automática de schema: algumas bases usam "period", outras "tempo". Essa linha é uma adaptação à realidade fragmentada do ecossistema de dados de futebol — evita que o script quebre se a base for trocada.

O purrr::compact() remove elementos NULL (quando uma das metades tem <4 passes ou não existe), permitindo que o resto do código trate apenas o caso com dados.

17.2 Escalas compartilhadas

lim_s <- range(unlist(map(ds, ~ .x$nos$valor_no)))
lim_l <- range(unlist(map(ds, ~ .x$conexoes$freq)))

Esses dois limites são calculados antes de plotar qualquer painel, varrendo as duas metades. São passados como size_lim e lw_lim para base_plot_tactics(), garantindo que um nó de 15 passes no 1º tempo tenha exatamente o mesmo diâmetro no 2º tempo. Sem isso, cada painel criaria sua própria escala e o comparativo seria enganoso — um erro clássico em visualização que o script evita explicitamente.

17.3 Painel condicional com fallback

painel <- function(d, titulo) {
  if (is.null(d)) {
    return(ggplot() + theme_void() +
      annotate("text", x = 0, y = 0, label = "Dados insuficientes", ...) +
      labs(title = titulo) + ...)
  }
  base_plot_tactics(d$conexoes, d$nos, titulo, "Passes de ruptura",
                    aspect = 1.5, esc = 0.75,
                    size_lim = lim_s, lw_lim = lim_l)
}

Se uma das metades não tem dados suficientes, o painel não desaparece — ele vira uma mensagem “Dados insuficientes”. Isso preserva a estrutura de dois painéis e mantém a simetria do comparativo, mesmo em casos-limite (jogo cancelado no intervalo, base incompleta, etc.).

O aspect = 1.5 e esc = 0.75 são específicos do comparativo: painéis mais largos e textos 25% menores para caber lado a lado em 1080×1080.

17.4 Composição com patchwork

p_comp <- painel(d1, "1º TEMPO") + painel(d2, "2º TEMPO") +
  plot_layout(guides = "collect") +
  plot_annotation(
    title = paste("EVOLUÇÃO TÁTICA -", nome_exibicao),
    subtitle = "Campo Analítico",
    theme = theme(...)
  ) &
  theme(legend.position = "bottom")

Três detalhes importantes:

  1. plot_layout(guides = "collect"): as duas legendas (uma de cada painel) são unificadas em uma só ao final. Sem isso, o comparativo teria duas barras de legenda idênticas, ocupando o dobro de espaço — visualmente poluído e redundante.
  2. plot_annotation() adiciona título e subtítulo globais ao conjunto dos dois painéis — não a cada painel individualmente.
  3. & theme(legend.position = "bottom") aplica o tema a todos os painéis via operador & do patchwork (diferente de +, que aplicaria só ao último).

Essa combinação guides = "collect" + plot_annotation + & theme é o padrão canônico do patchwork para composições comparativas. O script a executa corretamente, o que não é trivial.


18. Leitura dos Dados

arquivo <- "C:/Users/aldre/Downloads/Corinthians_vs_Vitoria_base2024.csv"
partida <- read_csv(arquivo, show_col_types = FALSE)

Duas observações:

  1. Caminho absoluto hardcoded — um ponto frágil. Em produção, isso deveria vir de here::here(), de uma variável de ambiente ou de commandArgs(). Mas para um script pessoal de análise, é aceitável e reduz o atrito.
  2. show_col_types = FALSE suprime o aviso de parsing do readr, que em bases grandes pode poluir o console com centenas de linhas. É uma escolha pragmática: o analista confia no schema da base.

18.1 Checagem exploratória

table(partida$teamId)
table(partida$teamName)
# names(partida); glimpse(partida)
# table(partida$type); ...

Três table() executados e várias linhas comentadas de diagnóstico. O padrão é claro: o autor verifica manualmente teamId, teamName e a estrutura geral antes de rodar as análises. As linhas comentadas (names, glimpse, table($type)) são um “kit de emergência” pronto para ser descomentado quando algo quebra.

Esse é um contraste interessante com a parte automatizada do pipeline: mesmo com toda a robustez, o analista mantém pontos de inspeção manual. É o reconhecimento de que dados de eventos sempre têm particularidades.


19. O Bloco de Saídas

dir_saida <- "saidas_santos"
dir.create(dir_saida, showWarnings = FALSE)

O diretório de saída é nomeado "saidas_santos" — resquício de um script anterior (o time analisado é Corinthians). É um detalhe revelador: o pipeline foi reaproveitado de outro contexto, o que reforça sua modularidade.

O showWarnings = FALSE evita o aviso “directory already exists” quando o script roda várias vezes seguidas. É o comportamento esperado em pipelines idempotentes.

19.1 Iteração com purrr::iwalk()

relatorio[names(relatorio) != "comparativo"] %>%
  purrr::compact() %>%
  purrr::iwalk(~ salvar_plot(.x, file.path(dir_saida, paste0("santos_", .y, ".png"))))

Esse é um dos trechos mais elegantes do script. Vamos destrinchá-lo:

  1. Filtro names(relatorio) != "comparativo" — remove o comparativo, que será salvo com dimensões diferentes.
  2. purrr::compact() — remove elementos NULL (ex.: se p_ruptura for NULL por falta de dados). Impede que salvar_plot() tente salvar um plot inexistente.
  3. purrr::iwalk() — itera sobre a lista nomeada passando o valor e o nome (~ .x = valor, ~ .y = nome). É a função certa para quando o nome do elemento precisa ser usado no corpo da iteração.
  4. paste0("santos_", .y, ".png") — cria nomes de arquivo descritivos automaticamente: santos_geral.png, santos_ruptura.png, santos_bloco_baixo.png, etc.

Em cinco linhas de código, o pipeline gera cinco arquivos PNG diferentes com nomes corretos e apenas os que existem. É o tipo de código que separa um script de um pipeline.

19.2 Saída do comparativo

if (!is.null(relatorio$comparativo)) {
  salvar_plot(relatorio$comparativo,
              file.path(dir_saida, "santos_comparativo.png"),
              largura_px = 1080, altura_px = 1440)
}

Curiosidade: o comentário do topo do script diz que o comparativo é 1080×1080, mas a chamada real usa 1080×1440. É uma inconsistência de documentação — mas provavelmente intencional: como o comparativo tem dois painéis lado a lado e uma legenda embaixo, 1440px de altura dá mais espaço para o campo não ficar “esmagado”.

O if (!is.null(...)) é a proteção contra o caso em que nenhuma das metades tem dados e o comparativo não pôde ser gerado.


20. Pré-visualização

print(relatorio$geral)
print(relatorio$ruptura)
print(relatorio$bloco_baixo)
print(relatorio$bloco_medio)
print(relatorio$bloco_alto)
print(relatorio$comparativo)

Seis print() explícitos ao final do script. Isso é uma escolha deliberada: dentro de um script Rscript não há exibição automática, e dentro do RStudio os print() garantem que cada gráfico seja renderizado em sequência na aba Plots.

O padrão é o oposto de walk(relatorio, print) — que seria mais conciso, mas perderia a intenção clara de cada linha. Aqui, o autor prioriza legibilidade do fluxo sobre elegância funcional. São escolhas defensáveis em ambos os lados.


21. O Que Este Script Ensina

Depois dessa leitura completa, algumas lições arquiteturais se destacam:

21.1 Separação de responsabilidades

A estrutura em camadas — configuração → utilitários → plotagem → processamento → orquestração → saída — faz com que qualquer mudança fique contida em uma única camada. Trocar de time? Altera o gerar_relatorio_tatico(). Trocar a paleta? Altera o ST. Trocar a fonte? Altera o font_add_google().

21.2 Robustez silenciosa

O script raramente falha: ele degrada graciosamente. Sem dados suficientes, retorna NULL. Sem internet, cai para "sans". Sem inteiros nos breaks, usa fallback. Um painel do comparativo ausente vira “Dados insuficientes”. Essa resiliência é o que diferencia um script de análise descartável de um pipeline utilizável em produção.

21.3 Comparabilidade explícita

A ideia de calcular size_lim e lw_lim antes de plotar os dois painéis é um princípio de visualização séria: escalas compartilhadas são obrigatórias em comparações. Sem isso, o gráfico mente. O código implementa essa regra como feature, não como afterthought.

21.4 Composição declarativa com patchwork

O uso de plot_layout(guides = "collect") + plot_annotation() + & theme() demonstra domínio de uma das bibliotecas mais poderosas do ecossistema R moderno. O comparativo é, tecnicamente, dois gráficos independentes tratados como um só.

21.5 Hierarquia tipográfica e respiro

O contraste Roboto Slab (serifada) no título × sans-serif no resto, somado às margens de 22pt, à legenda com spacing.x = 1.2cm e ao glossário no caption, produzem um resultado que comunica antes de ser lido. É design editorial aplicado à análise de dados.


22. Um Pipeline, Não Um Script

O que separa este código de um script exploratório é que ele foi desenhado para rodar várias vezes sobre bases diferentes, sempre produzindo os mesmos seis artefatos com a mesma qualidade. A assinatura de um pipeline maduro está lá:

  • Uma linha para gerar tudo: gerar_relatorio_tatico(partida, 1237, "Corinthians").
  • Nomenclatura automática: os arquivos recebem nome a partir do time e do tipo de análise.
  • Idempotência: rodar duas vezes não gera erro, sobrescreve os arquivos.
  • Degradação controlada: casos-limite (sem dados, sem internet) não quebram o fluxo.
  • Identidade visual blindada: qualquer alteração de cor, fonte ou dimensão está centralizada em ST e salvar_plot().

Para quem quer adaptar esse código ao seu próprio fluxo, os pontos de entrada são claros:

  1. Substituir o caminho do arquivo no topo.
  2. Ajustar ST se a identidade visual for de outro time/analista.
  3. Eventualmente parametrizar o id_do_time e o nome_exibicao (hoje passados na chamada de gerar_relatorio_tatico()).

O resto — extração, classificação, plotagem, composição, salvamento — já está resolvido.


23. Conclusão da Série

Este artigo percorreu, em duas partes, os 250+ linhas de código que compõem o pipeline de redes de passes. Vimos como a arquitetura em camadas permite manter clareza mesmo com alta densidade funcional, como o ggplot2 é estendido com ggsoccer, ggrepel, patchwork e showtext, e como pequenas decisões — de luminância de texto a quebras inteiras nas escalas — separam uma visualização amadora de um relatório tático profissional.

O maior mérito do código não está em nenhuma linha isolada, mas na coerência do conjunto: cada escolha reforça as demais, e o resultado é um pipeline que se comporta como um produto analítico, não como um script de exploração. É um excelente modelo para quem deseja construir ferramentas similares — seja para um clube, para uma consultoria ou para análise pública em redes sociais.

Se você for replicar ou estender este código, a recomendação final é: mantenha as camadas. Resista à tentação de “só colocar mais um caso no meio”. Cada nova funcionalidade tem um lugar claro — nos utilitários, no processamento ou na orquestração — e respeitar essa fronteira é o que vai manter o pipeline utilizável daqui a seis meses, quando você já não se lembrar de nenhuma das decisões originais.

🚀 Domine a Análise de Dados com a Formação Completa

Aprenda do zero ao avançado com o método Campo Analítico. Garanta sua vaga!

Quero me inscrever agora