Dois tipos de dado pedem visualizações que o ggplot2 estático não entrega bem: dados espaciais (onde as coisas acontecem — clientes, sensores, ocorrências) e dados relacionais (como as coisas se conectam — pessoas, transações, dependências). Ambos ganham enormemente com interatividade: dar zoom num bairro, clicar num nó e ver seus vizinhos, filtrar uma rede por comunidade. Este módulo cobre os dois cavalos de batalha do Shiny para isso: leaflet (mapas) e visNetwork (grafos), com foco na atualização eficiente via proxy.

Construir mapas interativos com leaflet alimentados por dados sf, atualizando marcadores/polígonos sem recriar o mapa (leafletProxy); e construir redes de relacionamento com visNetwork, capturando eventos de seleção e integrando-as ao grafo reativo do Shiny.

O modelo de dados espacial: sf

Antes do mapa, os dados. O pacote sf (simple features) é o padrão moderno para dados geoespaciais em R: um data.frame comum com uma coluna especial de geometria. Isso significa que tudo que você já sabe de dplyr funciona — filter, mutate, group_by — e a geometria vai junto.

library(sf)
library(dplyr)

# Um sf é um data.frame + coluna de geometria (pontos, linhas, polígonos)
pontos <- st_as_sf(
  data.frame(
    nome = c("Loja A", "Loja B", "Loja C"),
    vendas = c(120, 85, 200),
    lon = c(-46.63, -46.65, -46.60),
    lat = c(-23.55, -23.56, -23.54)
  ),
  coords = c("lon", "lat"),   # colunas que viram a geometria
  crs = 4326                  # sistema de coordenadas: WGS84 (lon/lat GPS)
)

print(pontos)          # note a coluna 'geometry' e o cabeçalho com CRS
st_crs(pontos)         # inspeciona o sistema de referência
st_bbox(pontos)        # caixa delimitadora (útil para enquadrar o mapa)

# dplyr funciona normalmente; a geometria acompanha
top <- pontos |> filter(vendas > 100)

O conceito de CRS (Coordinate Reference System) é onde iniciantes erram: leaflet espera coordenadas em WGS84 (EPSG:4326), lon/lat. Se seu shapefile vier em outra projeção (ex.: UTM, ou a SIRGAS 2000 comum no Brasil), converta com st_transform(dados, 4326) antes de mapear, ou os pontos aparecerão no oceano — ou em lugar nenhum.

# Shapefile numa projeção métrica → reprojetar para lon/lat antes do leaflet
municipios <- st_read("municipios.shp") |>
  st_transform(4326)

Um mapa leaflet básico

O leaflet usa a mesma gramática de camadas do ggplot2, encadeada com o pipe: você começa com o mapa, adiciona tiles (o fundo), e sobrepõe marcadores/polígonos.

library(leaflet)

leaflet(pontos) |>
  addTiles() |>                                  # fundo padrão (OpenStreetMap)
  addCircleMarkers(
    radius = ~sqrt(vendas),                       # tamanho ∝ vendas
    color = "steelblue", stroke = FALSE, fillOpacity = 0.7,
    label = ~nome,                                # tooltip no hover
    popup = ~paste0("<b>", nome, "</b><br>Vendas: ", vendas)  # ao clicar
  ) |>
  setView(lng = -46.63, lat = -23.55, zoom = 13)

Distinções úteis: label aparece no hover (texto simples); popup aparece no clique (aceita HTML). Para polígonos (regiões), use addPolygons; para uma escala de cores por valor, use uma paleta:

# Coropleto: cor do polígono ∝ um valor
pal <- colorNumeric("YlOrRd", domain = municipios$idh)

leaflet(municipios) |>
  addProviderTiles(providers$CartoDB.Positron) |>  # fundo mais clean
  addPolygons(
    fillColor = ~pal(idh), fillOpacity = 0.8,
    color = "white", weight = 1,                    # bordas
    label = ~paste0(nome, ": ", round(idh, 3)),
    highlightOptions = highlightOptions(            # destaque no hover
      weight = 3, color = "#666", fillOpacity = 0.9, bringToFront = TRUE)
  ) |>
  addLegend(pal = pal, values = ~idh, title = "IDH", position = "bottomright")

O ponto crítico: leafletProxy no Shiny

Aqui está a lição mais importante do módulo. Num app Shiny, se você reconstrói o mapa inteiro dentro de renderLeaflet a cada filtro, o mapa volta ao zoom inicial, pisca e fica lento — porque recria o widget do zero. A solução é renderizar o mapa uma vez (só o fundo) e depois atualizar as camadas com leafletProxy.

library(shiny)
library(leaflet)
library(dplyr)

ui <- fluidPage(
  sliderInput("min_vendas", "Vendas mínimas:", 0, 200, 0),
  leafletOutput("mapa", height = 600)
)

server <- function(input, output, session) {

  # 1) O mapa BASE é renderizado UMA vez (só o fundo e o enquadramento).
  output$mapa <- renderLeaflet({
    leaflet() |>
      addProviderTiles(providers$CartoDB.Positron) |>
      setView(-46.63, -23.55, zoom = 12)
  })

  # 2) Os dados filtrados são um reactive comum.
  dados <- reactive({
    pontos |> filter(vendas >= input$min_vendas)
  })

  # 3) Um observe ATUALIZA as camadas via proxy — sem recriar o mapa.
  observe({
    leafletProxy("mapa", data = dados()) |>
      clearMarkers() |>                    # remove marcadores antigos
      addCircleMarkers(
        radius = ~sqrt(vendas), color = "steelblue",
        fillOpacity = 0.7, label = ~nome
      )
  })
}

shinyApp(ui, server)

O padrão em três passos — (1) render base uma vez, (2) reactive de dados, (3) observe que aplica o proxy — é a espinha dorsal de todo mapa Shiny sério. clearMarkers()/clearShapes() limpa a camada antes de redesenhar; o zoom e o pan do usuário são preservados. É exatamente o mesmo princípio do plotlyProxy, visto em visualizações sincronizadas.

Reagindo a cliques no mapa

Todo elemento do leaflet no Shiny emite eventos acessíveis como input$MAPID_marker_click, input$MAPID_shape_click, etc. O evento traz id, lat, lng.

# Dê um layerId aos marcadores para identificá-los no clique
observe({
  leafletProxy("mapa", data = dados()) |>
    clearMarkers() |>
    addCircleMarkers(layerId = ~nome, radius = ~sqrt(vendas), label = ~nome)
})

# Capture o clique: input$mapa_marker_click traz $id (= o layerId)
observeEvent(input$mapa_marker_click, {
  clicado <- input$mapa_marker_click$id
  showNotification(paste("Você clicou em:", clicado))
  # ... atualizar outro painel, filtrar uma tabela, etc.
})

O layerId é o key do mundo leaflet: ele carrega a identidade do elemento clicado até o servidor, exatamente como o key do plotly.

Redes de relacionamento com visNetwork

Dados relacionais — quem transaciona com quem, quais processos dependem de quais, como usuários se conectam — pedem um grafo. O visNetwork é a melhor opção interativa em R: física de layout automática, zoom, arraste de nós e eventos, tudo via a biblioteca vis.js.

Um grafo precisa de dois data frames: nós (com id obrigatório) e arestas (com from e to).

library(visNetwork)

nos <- data.frame(
  id    = 1:5,
  label = c("Ana", "Bruno", "Carla", "Diego", "Eva"),
  group = c("vendas", "vendas", "TI", "TI", "diretoria"),
  value = c(10, 25, 15, 30, 20)      # tamanho do nó
)

arestas <- data.frame(
  from = c(1, 2, 2, 3, 4, 5),
  to   = c(2, 3, 4, 4, 5, 1),
  value = c(1, 5, 2, 8, 3, 4)        # espessura da aresta
)

visNetwork(nos, arestas, width = "100%", height = "500px") |>
  visNodes(shape = "dot") |>
  visGroups(groupname = "vendas",    color = "#e41a1c") |>
  visGroups(groupname = "TI",        color = "#377eb8") |>
  visGroups(groupname = "diretoria", color = "#4daf4a") |>
  visLegend() |>
  visOptions(
    highlightNearest = TRUE,          # clicar num nó realça vizinhos
    nodesIdSelection = TRUE           # dropdown para selecionar nó
  ) |>
  visPhysics(stabilization = TRUE)    # estabiliza o layout ao abrir

Recursos que fazem diferença em uso real: highlightNearest = TRUE (clicar num nó destaca seus vizinhos e apaga o resto — essencial para redes densas), visOptions(selectedBy = "group") (filtrar por atributo), e visIgraphLayout() para layouts determinísticos e rápidos em grafos grandes (delega o cálculo ao igraph).

# Para redes grandes, use layout do igraph (muito mais rápido)
visNetwork(nos, arestas) |>
  visIgraphLayout(layout = "layout_with_fr") |>   # Fruchterman-Reingold
  visOptions(highlightNearest = list(enabled = TRUE, degree = 2))

visNetwork dentro do Shiny com eventos

No Shiny, o visNetwork também expõe eventos e tem seu próprio proxy (visNetworkProxy). O padrão de eventos: habilite com visEvents e leia via input$NETID_selected ou um evento customizado.

library(shiny)
library(visNetwork)

ui <- fluidPage(
  visNetworkOutput("rede", height = 550),
  verbatimTextOutput("selecionado")
)

server <- function(input, output, session) {

  output$rede <- renderVisNetwork({
    visNetwork(nos, arestas) |>
      visOptions(highlightNearest = TRUE, nodesIdSelection = TRUE) |>
      # Emite um input do Shiny quando um nó é clicado
      visEvents(selectNode = "function(props) {
        Shiny.setInputValue('no_clicado', props.nodes[0]);
      }")
  })

  # Lê o nó clicado (enviado pelo JS acima)
  observeEvent(input$no_clicado, {
    pessoa <- nos$label[nos$id == input$no_clicado]
    output$selecionado <- renderPrint(cat("Selecionado:", pessoa))
  })

  # Exemplo de proxy: focar num nó via botão, sem recriar a rede
  observeEvent(input$focar_ana, {
    visNetworkProxy("rede") |>
      visFocus(id = 1, scale = 2) |>
      visSelectNodes(id = 1)
  })
}

shinyApp(ui, server)

Note a ponte JS↔R via Shiny.setInputValue(...) dentro de visEvents: é a primeira aparição de um tema aprofundado em programação em Shiny (comunicação customizada entre o navegador e o servidor). Por ora, guarde o padrão: um widget dispara um evento JS, esse JS chama Shiny.setInputValue('nome', valor), e no R você lê input$nome.

Integrando mapa e rede num dashboard

O poder aparece ao cruzar as duas visualizações com o estado central das visualizações sincronizadas: clicar num município no mapa filtra a rede de relacionamentos daquela região; selecionar um nó na rede centraliza o mapa na sua localização. A mecânica é sempre a mesma — eventos escrevem no reactiveValues, os widgets leem via proxy.

server <- function(input, output, session) {
  estado <- reactiveValues(regiao = NULL, pessoa = NULL)

  # Clique no mapa → atualiza a região no estado
  observeEvent(input$mapa_shape_click, {
    estado$regiao <- input$mapa_shape_click$id
  })

  # Clique na rede → atualiza a pessoa no estado
  observeEvent(input$no_clicado, {
    estado$pessoa <- input$no_clicado
  })

  # A rede reage à região (proxy, sem recriar)
  observe({
    req(estado$regiao)
    visNetworkProxy("rede") |>
      visUpdateNodes(nodes = nos_da_regiao(estado$regiao))
  })

  # O mapa reage à pessoa selecionada (proxy, sem recriar)
  observe({
    req(estado$pessoa)
    loc <- localizacao(estado$pessoa)
    leafletProxy("mapa") |> setView(loc$lng, loc$lat, zoom = 14)
  })
}

O sf é o padrão de dados espaciais — um data.frame com coluna de geometria, sobre o qual o dplyr funciona normalmente. O tropeço mais comum é o CRS: o leaflet exige WGS84, o EPSG:4326, e reprojetar é st_transform(dados, 4326). O leaflet monta o mapa por camadas encadeadas, com addCircleMarkers e addPolygons, label para hover e popup para clique, e paletas via colorNumeric com addLegend. Dentro do Shiny, o leafletProxy é essencial: renderize o mapa base uma vez e atualize as camadas pelo proxy, o que preserva zoom e pan do usuário. Os cliques chegam como input$MAPID_marker_click$id, e é o layerId que identifica o elemento tocado. Para grafos, o visNetwork parte de nós com id e arestas com from e to, tendo highlightNearest, visIgraphLayout e visNetworkProxy como recursos centrais. Eventos de widgets viram input pela ponte JS↔R do Shiny.setInputValue. E cruzar mapa com rede segue o padrão de sempre: eventos escrevem no estado, widgets leem via proxy.

Exercícios

  1. Seu shapefile abre no R, mas os polígonos não aparecem no leaflet (o mapa fica só com o fundo). Qual a causa mais provável e a correção?

    ✓ Resposta:

    O CRS mais provavelmente não é WGS84. O leaflet só entende coordenadas em lon/lat (EPSG:4326); se o shapefile estiver numa projeção métrica (UTM, SIRGAS 2000 etc.), os polígonos caem fora da área visível ou em coordenadas inválidas. A correção é reprojetar antes de mapear: dados <- st_transform(dados, 4326). Verifique sempre com st_crs(dados).

  2. Por que atualizar um mapa Shiny com leafletProxy é preferível a reconstruí-lo dentro de renderLeaflet a cada filtro?

    ✓ Resposta:

    Reconstruir dentro de renderLeaflet recria o widget inteiro: o mapa volta ao setView inicial, perde o zoom/pan do usuário, pisca e é lento (recarrega tiles). leafletProxy modifica o mapa já existente — limpa e redesenha só a camada de marcadores/polígonos com clearMarkers()/addCircleMarkers() — preservando a posição da câmera e sendo muito mais rápido. Padrão: renderizar o base uma vez, atualizar via proxy.

  3. No visNetwork, o que highlightNearest = TRUE faz e por que é importante em redes densas?

    ✓ Resposta:

    Ao clicar (ou passar o mouse) num nó, highlightNearest realça o nó e seus vizinhos diretos e esmaece todo o resto do grafo. Em redes densas — dezenas ou centenas de nós com muitas arestas — o emaranhado torna impossível enxergar relações; o realce isola a vizinhança do nó de interesse, tornando a estrutura local legível. Pode-se ampliar o alcance com degree = 2 para incluir vizinhos de vizinhos.

  4. Explique a ponte Shiny.setInputValue('no_clicado', props.nodes[0]) dentro de visEvents. O que ela conecta?

    ✓ Resposta:

    Ela conecta um evento do navegador (JavaScript) a um input do Shiny (R). Quando o usuário seleciona um nó, o vis.js chama a função JS registrada em visEvents(selectNode = ...); dentro dela, Shiny.setInputValue('no_clicado', props.nodes[0]) cria/atualiza input$no_clicado no servidor com o id do nó. No R, observeEvent(input$no_clicado, ...) reage. É a técnica geral para levar qualquer interação de um widget JS até a reatividade do Shiny.

  5. Ao cruzar mapa e rede, por que usar um reactiveValues central em vez de fazer o clique do mapa atualizar a rede diretamente (e vice-versa)?

    ✓ Resposta:

    Porque ligar os widgets diretamente cria dependências cruzadas e bidirecionais que rapidamente viram um ciclo difícil de depurar (mapa atualiza rede que atualiza mapa...). Com um estado central, cada clique apenas escreve um campo (estado$regiao, estado$pessoa), e cada widget apenas lê o estado via proxy. O fluxo fica unidirecional e previsível, é fácil adicionar um terceiro painel, e não há risco de laços de atualização — a mesma vantagem arquitetural das visualizações sincronizadas.

Fontes e leituras recomendadas