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
-
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
leafletsó 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 comst_crs(dados). -
Por que atualizar um mapa Shiny com
leafletProxyé preferível a reconstruí-lo dentro derenderLeafleta cada filtro?✓ Resposta:Reconstruir dentro de
renderLeafletrecria o widget inteiro: o mapa volta aosetViewinicial, perde o zoom/pan do usuário, pisca e é lento (recarrega tiles).leafletProxymodifica o mapa já existente — limpa e redesenha só a camada de marcadores/polígonos comclearMarkers()/addCircleMarkers()— preservando a posição da câmera e sendo muito mais rápido. Padrão: renderizar o base uma vez, atualizar via proxy. -
No
visNetwork, o quehighlightNearest = TRUEfaz e por que é importante em redes densas?✓ Resposta:Ao clicar (ou passar o mouse) num nó,
highlightNearestrealç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 comdegree = 2para incluir vizinhos de vizinhos. -
Explique a ponte
Shiny.setInputValue('no_clicado', props.nodes[0])dentro devisEvents. 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.jschama a função JS registrada emvisEvents(selectNode = ...); dentro dela,Shiny.setInputValue('no_clicado', props.nodes[0])cria/atualizainput$no_clicadono 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. -
Ao cruzar mapa e rede, por que usar um
reactiveValuescentral 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.