Um dashboard profissional raramente tem um gráfico. Tem vários — um mapa, uma série temporal, um ranking, uma tabela — e o valor está em fazê-los conversar: clicar numa barra filtra o mapa; selecionar uma região destaca os pontos correspondentes na dispersão; passar o mouse numa linha realça a mesma categoria em todos os painéis. Este módulo é sobre construir essa orquestração de duas formas complementares: no cliente (com crosstalk, sem ida ao servidor) e no servidor (com o grafo reativo e eventos de plotly).

Dominar a sincronização de múltiplas visualizações: linkar seleção e filtro entre widgets com crosstalk; capturar eventos de clique, hover e seleção com plotly via event_data(); e coordenar vários outputs pelo servidor usando reactive/reactiveValues, sabendo escolher entre a abordagem cliente e a servidor.

Duas filosofias de sincronização

Há duas maneiras de fazer visualizações se sincronizarem, e escolher errado custa performance ou flexibilidade:

  • Sincronização no cliente (crosstalk): os widgets compartilham um objeto de dados no navegador (SharedData). Quando você seleciona pontos num, os outros reagem instantaneamente, sem ir ao servidor. Ideal para brushing/linking rápido sobre um dataset que cabe no navegador (milhares, não milhões, de linhas). Limitação: só funciona entre widgets compatíveis com crosstalk (plotly, leaflet, DT, d3scatter) e apenas para seleção/filtro, não para transformações arbitrárias.
  • Sincronização no servidor (reatividade): cada clique vira um input, o servidor recalcula, e os outputs se redesenham. Mais poderosa (pode fazer qualquer transformação, agregação, consulta a banco), porém com a latência de um ciclo cliente→servidor→cliente.

Regra prática: filtro/seleção visual e leve → cliente (crosstalk); recalcular, agregar, consultar banco, mudar de dataset → servidor. Dashboards reais frequentemente combinam as duas.

Sincronização no cliente com crosstalk

O crosstalk envolve seu data frame num objeto SharedData. Qualquer widget compatível construído sobre esse objeto passa a compartilhar seleção e filtro.

library(crosstalk)
library(plotly)
library(DT)

# Um único SharedData alimenta todos os widgets.
# 'key' identifica cada linha unicamente entre os widgets.
sd <- SharedData$new(iris, key = ~row.names(iris))

# Dispersão que EMITE e RECEBE seleção
p <- plot_ly(sd, x = ~Sepal.Length, y = ~Petal.Length,
             color = ~Species, type = "scatter", mode = "markers") |>
  highlight(on = "plotly_selected", off = "plotly_deselect")

# Tabela que reflete a mesma seleção
tab <- datatable(sd, width = "100%")

# bscols dispõe lado a lado (funciona em .Rmd, Quarto e Shiny)
bscols(widths = c(6, 6), p, tab)

Ao selecionar pontos no gráfico (com a ferramenta de laço), as linhas correspondentes se destacam na tabela — sem uma linha de código de servidor. O segredo é o key: ele une as mesmas observações entre widgets.

Você também pode adicionar filtros que operam no cliente:

library(crosstalk)

sd <- SharedData$new(mtcars)

bscols(
  widths = c(3, 9),
  list(
    filter_slider("hp", "Potência (hp)", sd, ~hp),
    filter_checkbox("cyl", "Cilindros", sd, ~cyl, inline = TRUE)
  ),
  plot_ly(sd, x = ~wt, y = ~mpg, type = "scatter", mode = "markers")
)

Os filter_* do crosstalk filtram o SharedData no navegador; o gráfico reage na hora. Perfeito para relatórios Quarto/flexdashboard sem servidor Shiny.

Controlando o destaque (highlight)

plot_ly(sd, x = ~x, y = ~y) |>
  highlight(
    on  = "plotly_hover",      # o que ATIVA o destaque
    off = "plotly_doubleclick",# o que LIMPA
    color = "red",             # cor do destaque
    selectize = TRUE,          # caixa de busca para selecionar por valor
    dynamic = TRUE,            # permite escolher a cor dinamicamente
    persistent = TRUE          # acumula seleções em vez de substituir
  )

Sincronização no servidor: eventos de plotly

Quando você precisa de mais do que destacar/filtrar — recalcular uma agregação, consultar um banco, mudar completamente outro painel — a sincronização vai para o servidor. O plotly no Shiny expõe interações do usuário como eventos que você lê com event_data().

Os quatro eventos mais úteis:

  • plotly_click — clique num ponto/barra.
  • plotly_hover — mouse sobre um elemento.
  • plotly_selected — seleção por caixa/laço (retorna vários pontos).
  • plotly_relayout — zoom/pan (retorna a nova janela de eixos).
library(shiny)
library(plotly)
library(dplyr)

ui <- fluidPage(
  plotlyOutput("barras"),
  plotlyOutput("detalhe"),
  verbatimTextOutput("info")
)

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

  # Gráfico mestre: vendas por categoria
  vendas <- reactive({
    data.frame(
      categoria = c("A", "B", "C", "D"),
      total     = c(120, 90, 150, 60)
    )
  })

  output$barras <- renderPlotly({
    plot_ly(vendas(), x = ~categoria, y = ~total, type = "bar",
            source = "src_barras",           # nomeia a fonte de eventos
            key = ~categoria) |>              # 'key' identifica o clicado
      layout(title = "Clique numa categoria")
  })

  # Lê o clique no gráfico de barras
  categoria_sel <- reactive({
    ev <- event_data("plotly_click", source = "src_barras")
    req(ev)                                   # nada selecionado ainda → para
    ev$key[[1]]                               # a categoria clicada
  })

  # Gráfico detalhe: reage à categoria clicada (recalcula no servidor)
  output$detalhe <- renderPlotly({
    cat <- categoria_sel()
    detalhe <- data.frame(mes = month.abb[1:6],
                          valor = round(runif(6, 10, 40)))
    plot_ly(detalhe, x = ~mes, y = ~valor, type = "scatter", mode = "lines+markers") |>
      layout(title = paste("Detalhe de", cat))
  })

  output$info <- renderPrint({
    cat("Categoria selecionada:", categoria_sel())
  })
}

shinyApp(ui, server)

Pontos-chave desse padrão:

  1. source = "..." nomeia a fonte de eventos, para que event_data() saiba de qual gráfico ler (essencial quando há vários plotly na tela).
  2. key = ~coluna carrega a identidade do elemento clicado até o servidor.
  3. req(ev) impede erro antes do primeiro clique.
  4. O gráfico de detalhe é um reactive/render comum que depende de categoria_sel() — ou seja, toda a maquinaria da arquitetura reativa se aplica.

registerPlotlyEvents e priorizar eventos

Por padrão, alguns eventos (como plotly_hover) podem não estar "ligados". Você garante o registro configurando o gráfico e, quando o disparo precisa ser imediato/ignorar duplicatas, usa event_register:

output$g <- renderPlotly({
  plot_ly(df, x = ~x, y = ~y, source = "s") |>
    event_register("plotly_hover")   # garante que hover seja capturável
})

hover <- reactive({
  event_data("plotly_hover", source = "s", priority = "event")
  # priority = "event" dispara mesmo que o valor seja igual ao anterior
})

Coordenando vários painéis por um estado central

Quando três ou mais painéis interagem, espalhar event_data por todo lado vira espaguete. O padrão limpo é ter um estado central em reactiveValues que cada interação atualiza e cada painel lê. É o mesmo padrão de estado da arquitetura reativa, agora aplicado a visualização.

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

  # ESTADO CENTRAL do dashboard
  estado <- reactiveValues(
    regiao   = NULL,
    periodo  = c(2020, 2024),
    metrica  = "receita"
  )

  # Cada interação ATUALIZA o estado (efeito colateral → observeEvent)
  observeEvent(event_data("plotly_click", source = "mapa"), {
    estado$regiao <- event_data("plotly_click", source = "mapa")$key
  })
  observeEvent(input$metrica,  estado$metrica <- input$metrica)
  observeEvent(input$periodo,  estado$periodo <- input$periodo)

  # Um único reactive de dados derivado do estado (compartilhado, cacheado)
  dados_filtrados <- reactive({
    df |>
      filter(is.null(estado$regiao) | regiao == estado$regiao,
             ano >= estado$periodo[1], ano <= estado$periodo[2])
  })

  # Cada painel apenas LÊ dados_filtrados() — nenhum sabe dos outros
  output$serie   <- renderPlotly({ plot_ly(dados_filtrados(), x = ~ano, y = ~get(estado$metrica)) })
  output$ranking <- renderPlotly({ plot_ly(dados_filtrados() |> count(categoria), x = ~n, y = ~categoria, type = "bar") })
  output$tabela  <- DT::renderDT({ dados_filtrados() })
}

Por que isso é bom: cada painel depende de uma fonte de verdade (dados_filtrados()), que por sua vez deriva do estado. Adicionar um quarto painel não toca nos outros três. Esse desenho — interações escrevem no estado; painéis leem do estado — é o que torna dashboards complexos sustentáveis, e reaparece na arquitetura modular (programação em Shiny).

Sincronizando eixos e zoom (plotly_relayout)

Um pedido comum: dois gráficos empilhados que devem compartilhar o mesmo zoom no eixo X (ex.: preço e volume de uma ação). Captura-se o plotly_relayout de um e aplica-se ao outro via plotlyProxy (atualiza sem redesenhar tudo).

observeEvent(event_data("plotly_relayout", source = "preco"), {
  rl <- event_data("plotly_relayout", source = "preco")
  # rl traz xaxis.range[0] e xaxis.range[1] quando há zoom
  if (!is.null(rl[["xaxis.range[0]"]])) {
    plotlyProxy("volume", session) |>
      plotlyProxyInvoke("relayout",
        list(xaxis = list(range = c(rl[["xaxis.range[0]"]],
                                    rl[["xaxis.range[1]"]]))))
  }
})

plotlyProxy + plotlyProxyInvoke é o equivalente, para gráficos, do leafletProxy/dataTableProxy que veremos nos próximos módulos: modificar um widget existente sem recriá-lo, o que é crítico para performance.

Quando usar cliente, quando usar servidor (guia de decisão)

Situação Abordagem Ferramenta
Destacar/filtrar pontos entre gráfico e tabela, dataset pequenoClientecrosstalk + highlight/filter_*
Relatório sem servidor (Quarto, flexdashboard)Clientecrosstalk
Clicar numa barra e recalcular uma agregaçãoServidorevent_data("plotly_click")
Clicar num item e consultar o bancoServidorevent_data + DBI
Ajustar um widget já renderizado sem recriá-loServidorplotlyProxy/leafletProxy
Coordenar 3+ painéisServidorreactiveValues central

A escolha errada mais comum é usar o servidor para um simples brushing que o crosstalk faria de graça no navegador — introduzindo latência desnecessária. A segunda mais comum é tentar forçar o crosstalk a fazer uma agregação (que ele não faz) em vez de subir para o servidor.

Sincronizar painéis tem duas filosofias, e escolher errado custa caro. No cliente, o crosstalk é instantâneo mas só faz seleção e filtro; no servidor, a reatividade é poderosa e cobra latência. O crosstalk::SharedData liga plotly, leaflet e DT dentro do navegador, com o key unindo as mesmas linhas entre widgets e highlight() e filter_*() controlando o comportamento. Do lado do servidor, o event_data() traz as interações — clique, hover, seleção, relayout — com o key carregando a identidade do elemento e o req() evitando erro antes da primeira interação. A partir de três painéis, centralize tudo num reactiveValues e siga uma regra só: interações escrevem no estado, painéis leem do estado. Para atualizar um gráfico já desenhado sem recriá-lo, plotlyProxy e plotlyProxyInvoke. E o critério de escolha: cliente para brushing leve, servidor quando é preciso recalcular, agregar ou consultar.

Exercícios

  1. Você tem uma dispersão e uma tabela num relatório Quarto sem Shiny, e quer que selecionar pontos na dispersão destaque as linhas na tabela. Cliente ou servidor? Como?

    ✓ Resposta:

    Cliente, com crosstalk — não há servidor Shiny num Quarto estático, então a sincronização tem que acontecer no navegador. Envolva os dados num SharedData$new(df, key = ~id) e construa tanto o plot_ly() quanto o datatable() sobre esse mesmo objeto. A seleção por laço no gráfico destaca as linhas correspondentes na tabela automaticamente, sem código de servidor.

  2. Ao clicar numa barra, seu app precisa rodar uma consulta SQL que agrega milhões de registros. Cliente ou servidor? Por quê?

    ✓ Resposta:

    Servidor. O crosstalk só faz seleção/filtro sobre dados já presentes no navegador — ele não agrega nem consulta bancos, e milhões de registros não caberiam no cliente de qualquer forma. Capture o clique com event_data("plotly_click", source=), use o key para saber qual barra foi clicada, e dispare a consulta no servidor (idealmente com DBI e query parametrizada). O resultado alimenta os demais outputs.

  3. No exemplo com múltiplos plotly, por que o argumento source = "..." é necessário?

    ✓ Resposta:

    Porque event_data() precisa saber de qual gráfico ler o evento. Sem source, com vários plotly na página, os eventos se misturam e você não consegue distinguir um clique no mapa de um clique no ranking. Nomear cada gráfico com source = "mapa", source = "ranking" etc. e ler event_data(..., source = "mapa") isola corretamente cada fluxo de interação.

  4. Explique o padrão "interações escrevem no estado, painéis leem do estado" e por que ele escala melhor que ligar cada painel diretamente aos outros.

    ✓ Resposta:

    Em vez de cada painel observar os eventos de todos os demais (o que gera N×N conexões e vira espaguete), centraliza-se um reactiveValues que representa o estado do dashboard (região, período, métrica...). Cada interação (clique, filtro) apenas atualiza um campo do estado via observeEvent; cada painel apenas lê um reactive derivado do estado (ex.: dados_filtrados()). Assim, adicionar um novo painel é O(1): ele só precisa ler o estado, sem que os outros saibam de sua existência. Há uma única fonte de verdade, o que elimina inconsistências.

  5. Qual a vantagem de plotlyProxy(...) sobre simplesmente redesenhar o gráfico dentro de um renderPlotly a cada mudança?

    ✓ Resposta:

    plotlyProxy + plotlyProxyInvoke modifica o gráfico já existente no navegador (muda um range de eixo, adiciona um traço, atualiza dados) sem destruí-lo e recriá-lo do zero. Redesenhar via renderPlotly reconstrói todo o widget, perde o estado de zoom/pan do usuário, pisca visualmente e é mais lento — especialmente ruim com muitos pontos. O proxy é o mesmo princípio de leafletProxy/dataTableProxy: atualizações incrementais e suaves em vez de recriação completa.

Fontes e leituras recomendadas