Você já tem o plotly funcionando e sincronizado. Então por que aprender outra biblioteca de gráficos? Porque elas resolvem problemas diferentes. O plotly é o canivete suíço — cobre quase tudo, tem um proxy poderoso e uma API estável. O echarts4r é a interface R para o Apache ECharts, e traz três coisas que o plotly não entrega com a mesma facilidade: animações e transições nativas, um repertório visual maior (sunburst, sankey, gauge, calendário, grafos) e um sistema de tooltip que é, na prática, um pequeno mecanismo de template HTML. Num dashboard executivo, é frequentemente o tooltip que separa "um gráfico" de "um painel que a diretoria entende sozinha".

Esta aula é sobre integrar o echarts4r ao Shiny de verdade: capturar os eventos de mouse que ele devolve, sincronizar dois gráficos, e construir tooltips que mostram muito mais que um par de números.

Dominar a integração echarts4r + Shiny: conhecer o conjunto completo de eventos que o pacote publica em input$, sincronizar visualizações por esses eventos, escrever tooltips personalizadas com formatter em JavaScript e com templates de string em R, e saber onde o proxy do echarts4r ajuda — e onde ele decepciona.

A gramática: e_charts() e o encadeamento

O echarts4r segue a lógica do pipe, como o ggplot2 segue a do +. Começa-se declarando o eixo x com e_charts() e, a partir daí, cada camada é uma função e_*():

library(echarts4r)
library(dplyr)

vendas <- data.frame(
    mes   = factor(month.abb[1:6], levels = month.abb[1:6]),
    receita = c(120, 145, 132, 178, 165, 199),
    custo   = c( 90, 102,  98, 120, 118, 131)
)

vendas |>
    e_charts(mes) |>
    e_bar(receita, name = "Receita") |>
    e_line(custo, name = "Custo") |>
    e_tooltip(trigger = "axis") |>
    e_title("Desempenho do semestre")

Duas diferenças importantes em relação ao plotly:

  • Agrupamento vira série. Um group_by() antes do e_charts() faz o echarts4r criar uma série por grupo automaticamente. É o caminho natural para gráficos multi-série.
  • trigger = "axis" vs "item". No modo axis, passar o mouse em qualquer ponto da vertical mostra todas as séries daquele x — é o que se espera num gráfico temporal. No modo item (padrão de dispersões), mostra só o ponto sob o cursor.

O contrato de eventos com o Shiny

Aqui está o ponto que economiza horas: o echarts4r publica os eventos como inputs do Shiny cujo nome é o outputId do gráfico mais um sufixo fixo. Se o seu gráfico é echarts4rOutput("vendas"), você lê:

input o que traz
input$vendas_clicked_datao dado completo do ponto clicado (lista)
input$vendas_clicked_data_valuesó o valor do ponto clicado
input$vendas_clicked_rowo número da linha clicada
input$vendas_clicked_serieo nome da série clicada
input$vendas_mouseover_dataidem, para o ponto sob o cursor
input$vendas_mouseover_data_valueo valor sob o cursor
input$vendas_mouseover_rowa linha sob o cursor
input$vendas_mouseover_seriea série sob o cursor
input$vendas_brushos pontos selecionados por área (brush)
input$vendas_legend_changeo nome da série que teve a legenda alternada
input$vendas_legend_selectedo estado (ligado/desligado) de todas as séries

Compare com o plotly, que centraliza tudo em event_data("plotly_click", source = "..."). O echarts4r não precisa de source: o próprio outputId já faz esse papel. É mais simples de ler e mais difícil de confundir quando há muitos gráficos.

Um detalhe que causa erro na primeira execução: antes da primeira interação, esses inputs são NULL. Todo observeEvent/reactive que os consome precisa de req().

Clique, hover e brush na prática

library(shiny)
library(bslib)
library(echarts4r)
library(dplyr)

tb <- mtcars |>
    tibble::rownames_to_column("modelo") |>
    rename(peso = wt, consumo = mpg)

ui <- page_sidebar(
    title = "Eventos do echarts4r",
    sidebar = sidebar(
        width = 340,
        h6("Clique"),  verbatimTextOutput("log_clique"),
        h6("Hover"),   verbatimTextOutput("log_hover"),
        h6("Seleção"), verbatimTextOutput("log_brush")
    ),
    card(echarts4rOutput("dispersao", height = 460))
)

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

    output$dispersao <- renderEcharts4r({
        tb |>
            e_charts(peso) |>
            e_scatter(consumo, bind = modelo, symbol_size = 14) |>
            e_brush() |>                 # habilita a seleção por área
            e_tooltip() |>
            e_x_axis(scale = TRUE) |>
            e_y_axis(scale = TRUE)
    })

    output$log_clique <- renderPrint({
        req(input$dispersao_clicked_data)
        str(input$dispersao_clicked_data)
    })

    output$log_hover <- renderPrint({
        req(input$dispersao_mouseover_row)
        tb[input$dispersao_mouseover_row, c("modelo", "peso", "consumo")]
    })

    output$log_brush <- renderPrint({
        req(input$dispersao_brush)
        input$dispersao_brush
    })
}

shinyApp(ui, server)

Três observações que valem mais que o código:

  1. bind = modelo é o key do echarts4r. Sem ele, o clique devolve coordenadas e você não sabe qual registro foi clicado. Com ele, o identificador viaja junto e chega em params.name no tooltip e no evento.
  2. e_brush() precisa ser declarado. Ao contrário do plotly, a seleção por área não vem ligada por padrão.
  3. _clicked_row é ouro. Ele devolve o índice da linha no data frame original — o caminho mais curto para ir do clique de volta aos seus dados, sem parsing de strings.

A legenda como filtro de verdade

Num echarts4r multi-série, clicar na legenda liga e desliga séries. Isso é visual por padrão, mas os eventos de legenda permitem transformar esse gesto em filtro de aplicação: quando o usuário desliga "Região Sul", você pode recalcular uma tabela, um KPI ou uma consulta.

observeEvent(input$vendas_legend_change, {
    serie <- input$vendas_legend_change
    estado <- input$vendas_legend_selected   # lista nomeada: TRUE/FALSE por série
    visiveis <- names(estado)[unlist(estado)]

    estado_app$series_ativas <- visiveis     # escreve no estado central
})

Repare que isto é exatamente o padrão "interações escrevem no estado, painéis leem do estado" — só muda a origem do evento. É por isso que vale a pena ter o estado centralizado: trocar plotly por echarts4r num painel não obriga a reescrever os outros.

Sincronizando dois gráficos

Há dois caminhos, e a escolha segue a mesma lógica cliente/servidor.

No cliente, com e_connect() — o mais barato. Dois gráficos conectados compartilham tooltip e destaque, sem passar pelo servidor:

g1 <- vendas |> e_charts(mes, elementId = "g1") |> e_line(receita) |> e_tooltip(trigger = "axis")
g2 <- vendas |> e_charts(mes, elementId = "g2") |> e_bar(custo)    |> e_tooltip(trigger = "axis")

g1 |> e_connect("g2")   # passar o mouse num destaca o ponto correspondente no outro

O e_connect() exige elementId explícito nos gráficos e funciona só para tooltip e destaque — ele não filtra nem recalcula nada.

No servidor, via evento — quando o gesto num gráfico precisa mudar o conteúdo do outro:

selecionada <- reactiveVal(NULL)

observeEvent(input$categorias_clicked_data, {
    selecionada(input$categorias_clicked_data$name)
})

output$detalhe <- renderEcharts4r({
    req(selecionada())
    dados |>
        filter(categoria == selecionada()) |>
        group_by(mes) |>
        summarise(total = sum(valor)) |>
        e_charts(mes) |>
        e_line(total) |>
        e_title(paste("Evolução —", selecionada()))
})

Tooltips personalizadas: o formatter

O tooltip padrão mostra o que o ECharts acha relevante. Quase nunca é o que você quer. O controle vem do argumento formatter de e_tooltip(), e há dois caminhos.

Caminho A — JavaScript, com htmlwidgets::JS(). O ECharts entrega um objeto params para a função, e você monta a string de retorno:

tb |>
    e_charts(peso) |>
    e_scatter(consumo, qsec, bind = modelo) |>
    e_tooltip(formatter = htmlwidgets::JS("
        function(params) {
            return '<strong>' + params.name + '</strong><br/>' +
                   'Peso: '    + params.value[0] + '<br/>' +
                   'Consumo: ' + params.value[1] + '<br/>' +
                   'Aceleração: ' + params.value[2];
        }
    "))

Os campos úteis de params: name (o que veio no bind), value (vetor com as dimensões na ordem em que foram passadas), seriesName e dataIndex.

Caminho B — montar o HTML em R, e deixar o JS só devolvê-lo. É o truque que evita escrever JavaScript:

library(glue)

tb |>
    mutate(cartao = glue(
        "<strong>{modelo}</strong><br/>Peso: {peso} t<br/>Consumo: {consumo} mpg"
    )) |>
    e_charts(peso) |>
    e_scatter(consumo, bind = cartao) |>
    e_tooltip(formatter = htmlwidgets::JS("function(params){ return params.name; }"))

O bind passa a carregar o HTML pronto, e o formatter vira uma linha só. A vantagem prática é grande: toda a formatação — format(), scales::percent(), condicionais — acontece em R, onde você já sabe trabalhar, e fica testável.

Tooltip rica: um cartão com imagem

Levando o caminho B ao limite, o tooltip pode ser um cartão completo. É o recurso que transforma um gráfico num painel de consulta:

produtos <- produtos |>
    mutate(cartao = glue('
        <div style="width:240px;font-family:system-ui,sans-serif">
          <div style="display:flex;gap:10px;align-items:center">
            <img src="{url_foto}" style="width:56px;height:56px;border-radius:8px;object-fit:cover">
            <div>
              <div style="font-weight:700;font-size:14px">{nome}</div>
              <div style="font-size:12px;color:#5f6b7a">{categoria}</div>
            </div>
          </div>
          <div style="margin-top:8px;font-size:13px">
            Preço: <b>{scales::dollar(preco, prefix = "R$ ")}</b><br>
            Estoque: <b>{estoque}</b> un.
          </div>
        </div>
    '))

Duas armadilhas em produção:

  • As imagens precisam ser acessíveis pelo navegador. Um caminho de arquivo do servidor não funciona; use addResourcePath() para publicar a pasta, ou dados embutidos em base64.
  • Escape. Se nome vier do banco e contiver < ou &, o cartão quebra ou vira um vetor de injeção. Passe por htmltools::htmlEscape() antes do glue().

echarts4rProxy: expectativas calibradas

O echarts4r tem proxy — echarts4rProxy() com e_append*_p(), e_highlight_p(), e_focus_adjacency_p() — e ele serve bem para acrescentar dados a uma série existente e para destacar elementos sem redesenhar.

O que ele não faz bem, e é justo dizer: comparado ao plotlyProxy ou ao leafletProxy, a cobertura é menor. Mudar a escala de um eixo, trocar o conjunto de dados inteiro ou reconfigurar a legenda normalmente exige recriar o gráfico no renderEcharts4r. A regra prática: se a mudança é incremental (um ponto novo, um destaque), use proxy; se é estrutural, redesenhe e aceite o custo.

observeEvent(input$novo_ponto, {
    echarts4rProxy("serie_tempo") |>
        e_append1_p(serie_index = 0, x = Sys.time(), y = runif(1, 80, 120)) |>
        e_execute()   # não esqueça: sem e_execute() nada acontece
})

Esquecer o e_execute() no fim da cadeia é o erro mais comum com o proxy do echarts4r — o código roda, não dá erro, e nada muda na tela.

O echarts4r complementa o plotly com mais tipos de gráfico, animações nativas e tooltips bem mais flexíveis. Os eventos chegam ao Shiny no formato input$<outputId>_<sufixo> — _clicked_data, _clicked_row, _clicked_serie, _mouseover_*, _brush, _legend_change, _legend_selected —, e aqui não existe source: o próprio outputId identifica o gráfico. O bind = faz o papel do key, e sem ele o evento não diz qual registro foi tocado; o _clicked_row devolve o índice direto no data frame. O e_brush() precisa ser habilitado explicitamente, e como os inputs de evento são NULL antes da primeira interação, req() sempre. Para sincronizar, e_connect() resolve no cliente — só tooltip e destaque, e exige elementId —, enquanto evento mais estado central resolve no servidor, quando é preciso recalcular. No tooltip, dá para usar formatter com htmlwidgets::JS(), mas monta-se melhor o HTML em R com glue(), deixando o JS só devolver params.name — e escapando o que vier do banco. Por fim, o echarts4rProxy() serve para acrescentar e destacar; mudança estrutural pede redesenho, e sem e_execute() o proxy não faz nada.

Exercícios

  1. No plotly você precisa de source = "mapa" para distinguir eventos de gráficos diferentes. Por que o echarts4r não precisa de nada equivalente?

    ✓ Resposta:

    Porque o echarts4r deriva o nome do input do próprio outputId. Um gráfico em echarts4rOutput("mapa") publica input$mapa_clicked_data, e um em echarts4rOutput("ranking") publica input$ranking_clicked_data — os namespaces já são distintos por construção. No plotly, todos os eventos passam pela função única event_data(), então é preciso um rótulo (source) para dizer de qual gráfico se quer ler; sem ele, os eventos de vários gráficos se misturam.

  2. Você clicou num ponto e quer recuperar a linha completa do seu data frame, com todas as colunas — inclusive as que não foram plotadas. Qual evento usar, e por quê?

    ✓ Resposta:

    input$<id>_clicked_row. Ele devolve o índice da linha no data frame que alimentou o gráfico, então basta dados[input$graf_clicked_row, ] para ter o registro inteiro, com colunas que nunca foram desenhadas. As alternativas são piores: _clicked_data_value traz só os valores das dimensões plotadas, e _clicked_data traz a estrutura do ponto — ambos obrigariam a uma busca por valor, que é frágil quando há empates.

  3. Um colega usou e_connect() esperando que, ao filtrar por seleção num gráfico, o outro fosse recalculado. Não funcionou. Explique e proponha a correção.

    ✓ Resposta:

    O e_connect() é um recurso de cliente e puramente visual: ele sincroniza tooltip e destaque entre gráficos, nada mais. Ele não filtra dados, não agrega e não fala com o servidor. Para recalcular, é preciso ir pelo servidor: capturar o gesto com observeEvent(input$graf_clicked_data, ...) (ou _brush), gravar a seleção num reactiveVal/reactiveValues, e fazer o segundo gráfico ser um renderEcharts4r que lê esse estado, filtra os dados e redesenha.

  4. Qual a vantagem prática de montar o HTML do tooltip em R com glue() e usar formatter = JS("function(params){ return params.name; }"), em vez de escrever toda a lógica em JavaScript?

    ✓ Resposta:

    Toda a formatação fica em R, onde você já domina as ferramentas: scales::percent(), format(), ifelse() para regras condicionais, e pode inclusive testar o texto gerado com um teste unitário comum. O JavaScript fica reduzido a uma linha que nunca muda. Além disso, evita o problema de passar dados de R para dentro de uma string de JS (aspas, escapes, tipos), que é uma fonte recorrente de bugs difíceis de depurar no console do navegador.

  5. Você adicionou echarts4rProxy("serie") |> e_append1_p(...) num observeEvent e o gráfico não muda — sem nenhum erro no console. Qual a causa mais provável?

    ✓ Resposta:

    Falta o e_execute() no fim da cadeia. As funções *_p() do proxy apenas acumulam as operações; é o e_execute() que as envia ao widget no navegador. Como a cadeia é sintaticamente válida sem ele, nada falha — o código simplesmente não produz efeito, o que torna o problema especialmente enganoso. Se o e_execute() já estiver lá, as próximas suspeitas são o outputId errado no echarts4rProxy() e um serie_index inexistente (ele é baseado em zero).

Fontes e leituras recomendadas