How to Create a Drill-Down Bar Chart in Shiny with echarts4r
Data visualization is important for <strong>communicating results</strong>. Selecting the right graphical representation of your data can enhance your data storytelling. In this article, I'll show you how to implement <strong>drill-down </strong>using the <strong><a href="https://echarts4r.john-coene.com/" target="_blank" rel="noopener">echarts4r package</a></strong>, set and observe <strong>custom inputs</strong>, and use <strong><code>e_on()</code> observers</strong>. We'll do this in a <strong>Shiny app</strong>, as a way to demonstrate the functionality of interactive data. Why drill-down? Well, sometimes, data can be grouped into different <em>levels</em>. For example, you might have recorded sales data at a city level, which can be grouped at a country level, which can then be grouped into a larger level (such as a continent). This kind of data can be represented in different ways depending on what you want to communicate: <ul><li>If you are creating a static document, you could have one chart per level, showing all elements of a level at the same time.</li><li>If you are working on a Shiny Application, you could allow the user to select data using different inputs (for example, you can define one `selectInput` per level) and chart selected data.</li></ul> Sometimes, providing a lot of inputs doesn't make for a really good user experience. What if we could start with a chart at the top level (for example, sales by continent) and then allow the user to drill down to the level of their choice? <strong>Important note</strong>: This drill-down approach shouldn't be applied if you want to compare inner-level data across large-level data (in other words, if you want to compare countries between continents). TOC: <ul><li><a href="#drill-down">Drill-Down Charts</a></li><li><a href="#packages">Step 0: Install and Load the Required Libraries</a></li><li><a href="#sample-data">Step 1: Prepare Sample Data</a></li><li><a href="#bar-chart">Step 2: Create Bar Chart with echarts4r</a></li><li><a href="#logic">Step 3: Move Chart Creation Logic to a Function</a></li><li><a href="#plot">Step 4: Plot the Drill Down with 'plot_sales_data'</a></li><li><a href="#shiny">Step 5: Moving an eChart into a Shiny App</a></li><li><a href="#observers">Step 6: Add Bar Click Observers</a></li><li><a href="#bar-click">Step 7: Modify the Bar Chart on Bar Click</a></li><li><a href="#level">Step 8: Dynamically Modify the Bar Chart's level on Bar Click</a></li><li><a href="#go-back">Step 9: Add an Additional Observer to Go Back from the Drill</a></li></ul> <hr /> <h2 id="drill-down">What Are Drill-Down Charts?</h2> Drill-down functionality allows users to navigate through hierarchical data, starting with the highest level of data (in this case, continents) and then successively exploring lower levels of data (countries, and cities). In this article I will show you how to generate the following drill-down chart: <img class="alignnone size-full wp-image-17881" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01bdeafc0ff7c6577a423_drill_down_example.gif" alt="Drill-down chart example in echarts4r Shiny app" width="996" height="368" /> For example, in our sales data scenario, the user initially has access to sales by continent. Then, the user can select a continent to explore the total sales data for its countries. Finally, by clicking on a specific country within the continent, the user can view sales data at the city level. This allows the user to get a deeper understanding of the sales data by exploring it at different levels of granularity. <h2>Steps to Building a Drill-Down Chart in Shiny with echarts4R</h2> <h3 id="packages">Step 0: Install and Load the Required Libraries</h3> Before we begin, be sure to load the following libraries: <pre><code> ```{r, message = FALSE, warning = FALSE} # Load the required libraries. All these libraries are on CRAN. library(dplyr) library(echarts4r) library(glue) library(shiny) ``` </code></pre> <h3 id="sample-data">Step 1: Prepare Sample Data</h3> To showcase the drill-down functionality I generated mock data using <code>tibble::tribble()</code>. <pre><code> ```{r} # Create mock data mock_data <- tibble::tribble( ~level1 , ~level2 , ~level3 , ~sales, "Asia" , "China" , "Shanghai" , 32L, "Asia" , "China" , "Beijing" , 86L, "Asia" , "China" , "Chongqing" , 30L, "Asia" , "India" , "Mumbai" , 92L, "Asia" , "India" , "Kolkata" , 75L, "Asia" , "India" , "Chennai" , 99L, "America", "USA" , "New York" , 6L, "America", "USA" , "Chicago" , 12L, "America", "Argentina", "Buenos Aires" , 54L, "America", "Argentina", "Rosario" , 36L, "America", "Brasil" , "Sao Paulo" , 2L, "America", "Brasil" , "Rio de Janeiro", 64L, "Europe" , "Spain" , "Madrid" , 54L, "Europe" , "Spain" , "Barcelona" , 46L, "Europe" , "Spain" , "Sevilla" , 67L, "Europe" , "Italy" , "Rome" , 22L, "Europe" , "France" , "Paris" , 42L, "Europe" , "France" , "Marseille" , 91L ) ``` </code></pre> The mock data included sales information for cities that belong to countries, which in turn belong to continents. This nested data structure is excellent to show how the aggregated data at one level can be decomposed into more detailed information at the next level using the drill-down. <blockquote><a href="https://appsilon.com/r-ggmap/" target="_blank" rel="noopener">Want to visualize spatial data in R? Read our in-depth intro to R ggmap</a>.</blockquote> The column names were defined as <code>level1</code>, <code>level2</code> and <code>level3</code> instead of <code>continent</code>, <code>country</code> and <code>city</code>. This decision was made to ensure that the example was as general and flexible as possible. By using more abstract column names, the example can be applied to a wider range of data and use cases without becoming specific to this particular scenario. This is how the first rows of our mock data look like: <pre><code> ```{r} mock_data |> head() |> knitr::kable() ``` </code></pre> <blockquote>Looking to generate Word docs in Shiny? <a href="https://appsilon.com/generating-word-documents-from-table-data-in-shiny/" target="_blank" rel="noopener">See how with {officer}, {flextable}, and {shinyglide}</a>.</blockquote> <h3 id="bar-chart">Step 2: Create Bar Chart with echarts4r</h3> The following code uses <span class="pl-c1">`echarts4r`</span> to create a simple bar chart that summarizes sales data by continent: <pre><code> ```{r} # Prepare data for chart chart_data <- mock_data |> group_by(level = level1) |> summarise(total = sum(sales)) # Create chart chart_data |> e_chart(x = level) |> e_bar(total, name = "Sales by Continent", color = "#5470C6") ``` </code></pre> <h3 id="logic">Step 3: Move Chart Creation Logic to a Function</h3> In this Section the process of generating the chart was extracted into a function of its own named <code>plot_sales_data()</code>. <pre><code> ```{r, eval = FALSE} plot_sales_data <- function(chart_data, chart_title, chart_color) { chart_data |> e_chart(x = level) |> e_bar(total, name = chart_title, color = chart_color) } # Prepare data for chart chart_data <- mock_data |> group_by(level = level1) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = "Sales by Continent", chart_color = "#5470C6" ) ``` </code></pre> <h3 id="plot">Step 4: Plot the Drill Down with <code>plot_sales_data</code></h3> In this section we use <code>plot_sales_data()</code> to create a chart that summarizes sales by countries, but only for countries in a particular continent: America. This shows that we can use our function to create charts for different data levels by modifying the function's parameters. <pre><code> ```{r} plot_sales_data <- function(chart_data, chart_title, chart_color) { chart_data |> e_chart(x = level) |> e_bar(total, name = chart_title, color = chart_color) } # Prepare data for chart chart_data <- mock_data |> filter(level1 == "America") |> group_by(level = level2) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = "Sales by Country (Filtered for America)", chart_color = "#91CC75" ) ``` </code></pre> <h3 id="shiny">Step 5: Moving an eChart into a Shiny App</h3> To demonstrate the drill-down functionality, it is necessary to incorporate interactivity within an observable environment. <strong>Shiny</strong> is a great provider of such context and in combination with <strong>JavaScript</strong> we will be able to showcase the drill-down capabilities. <blockquote>Worried about s-l-o-w dashboards? <a href="https://appsilon.com/r-shiny-faster-updateinput-css-javascript/" target="_blank" rel="noopener">Make R Shiny faster with updateInput, CSS, and JavaScript</a>.</blockquote> The following code moves our first chart into a Shiny application. In future sections, we will add the drill functionality. <strong>CAVEAT</strong>: Before running the code, it is necessary to load the libraries and create the mock data (<strong>Step 0</strong> and <strong>Step 1</strong>, respectively). This code was not included in the code chunk below because it occupies a lot of space. <pre><code> ```{r, eval = FALSE} plot_sales_data <- function(chart_data, chart_title, chart_color) { chart_data |> e_chart(x = level) |> e_bar(total, name = chart_title, color = chart_color) } ui <- fluidPage( h1("Drill Down in Shiny"), echarts4rOutput("chart") ) # Define server server <- function(input, output) { output$chart <- renderEcharts4r({ # Prepare data for chart chart_data <- mock_data |> group_by(level = level1) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = "Sales by Continent", chart_color = "#5470C6" ) }) } shinyApp(ui, server) ``` </code></pre> When you launch the Shiny App, you should see the following screen: <img class="alignnone size-full wp-image-17883" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01bde30ca449bf549b669_step_5-echart-in-a-shiny-app.webp" alt="Step 5 of using echarts4 to build a drill-down chart in a Shiny app" width="1895" height="696" /> <h3 id="observers">Step 6: Add Bar Click Observers</h3> In this section, we'll add bar click observers to our chart. To do so, we will modify <code>plot_sales_data()</code>. <code><a href="https://echarts4r.john-coene.com/reference/callbacks.html" target="_blank" rel="nofollow noopener">echarts4r::e_on()</a></code> is used to add an event listener to the chart. The event listened to in this case is a <strong>click</strong> (defined by the <code>event</code> argument) on a <strong>bar</strong> of the chart (defined by the <code>query</code> argument). When a bar is clicked, the handler function (defined in <code>handler</code> argument) is executed in response. The <code>handler</code> contains the JavaScript code to execute when the <strong>click</strong> event is triggered. In this case, the <code><a href="https://shiny.rstudio.com/articles/communicating-with-js.html" target="_blank" rel="nofollow noopener">Shiny.setInputValue</a></code> function is called to set the value of a Shiny input named <code>custom_bar_click</code>. The input value is set to an object that contains information about the clicked bar, such as the <code>clicked_level</code> and <code>drilled_place</code>. The value is converted from JSON data in Javascript to a list in Shiny. <blockquote>Confused about Reactive Observers? <a href="https://appsilon.com/observe-function-r-shiny/" target="_blank" rel="noopener">Get started with two hands-on examples and the Shiny basics</a>.</blockquote> The <code>params</code> in the <code>handler</code> function is an object that contains information about the event that triggered the function (in this case, the <strong>click</strong> on the bar). We use the <strong>name</strong> property of this object, which is the name of the bar that was clicked. <strong>IMPORTANT</strong>: This Section is just to observe the click and print the custom value in our R console. You will see no changes in the chart. <img class="alignnone size-full wp-image-17885" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01be04367c4d011919c3e_step_6-add-bar-click-observers-for-drill-down.gif" alt="Step 6 result in adding a bar click observer for drill-down effect in Shiny chart" width="1898" height="867" /> <h3 id="bar-click">Step 7: Modify the Bar Chart on Bar Click</h3> In this section, we add the drill-down functionality from continent-to-country in our application. Country-to-city drill-down will be added in the next section. <pre><code> ```{r, eval = FALSE} plot_sales_data <- function(chart_data, chart_title, chart_color) { chart_data |> e_chart(x = level) |> e_bar(total, name = chart_title, color = chart_color) |> e_on( query = "series.bar", # Set input values handler = "function(params){ Shiny.setInputValue( 'custom_bar_click', {clicked_level: 'level2', drilled_place: params.name}, {priority: 'event'} ); }", event = "click" ) } ui <- fluidPage( h1("Drill Down in Shiny"), echarts4rOutput("chart") ) # Define server server <- function(input, output) { output$chart <- renderEcharts4r({ # Our custom input value that we send from the bar click print(input$custom_bar_click) if (is.null(input$custom_bar_click)) { # Prepare data for chart chart_data <- mock_data |> group_by(level = level1) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = "Sales by Continent", chart_color = "#5470C6" ) } else if(input$custom_bar_click$clicked_level == "level2") { # Prepare data for chart chart_data <- mock_data |> filter(level1 == input$custom_bar_click$drilled_place) |> group_by(level = level2) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = glue::glue( "Sales by Country (Filtered for {input$custom_bar_click$drilled_place})" ), chart_color = "#91CC75" ) } }) } shinyApp(ui, server) ``` </code></pre> This is the resulting application behavior in this step: <img class="alignnone size-full wp-image-17887" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01be1e7ac33b972b44aa8_step_7-modify-the-bar-chart-on-bar-click.gif" alt="Step 7 result from modifying the bar chart on a bar click" width="1863" height="666" /> <h3 id="level">Step 8: Dynamically Modify the Bar Chart's level on Bar Click</h3> In this section, a new parameter <code>chart_drill_to</code> is included in <code>plot_sales_data()</code>. This allows us to dynamically modify the bar chart's level on bar click. <pre><code> ```{r, eval = FALSE} plot_sales_data <- function(chart_data, chart_title, chart_color, chart_drill_to) { sales_chart <- chart_data |> e_chart(x = level) |> e_bar(total, name = chart_title, color = chart_color) # Adding the click observer only when drill_to is passed if (!is.null(chart_drill_to)) { sales_chart <- sales_chart |> e_on( query = "series.bar", # Set input values handler = glue::glue( "function(params){ Shiny.setInputValue( 'custom_bar_click', {clicked_level: '<>', drilled_place: params.name}, {priority: 'event'} ); }", .open = "<<", .close = ">>" ), event = "click" ) } return(sales_chart) } ui <- fluidPage( h1("Drill Down in Shiny"), echarts4rOutput("chart") ) # Define server server <- function(input, output) { output$chart <- renderEcharts4r({ # Our custom input value that we send from the bar click print(input$custom_bar_click) if (is.null(input$custom_bar_click)) { # Prepare data for chart chart_data <- mock_data |> group_by(level = level1) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = "Sales by Continent", chart_color = "#5470C6", chart_drill_to = "level2" ) } else if(input$custom_bar_click$clicked_level == "level2") { # Prepare data for chart chart_data <- mock_data |> filter(level1 == input$custom_bar_click$drilled_place) |> group_by(level = level2) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = glue::glue( "Sales by Country (Filtered for {input$custom_bar_click$drilled_place})" ), chart_color = "#91CC75", chart_drill_to = "level3" ) } else if(input$custom_bar_click$clicked_level == "level3") { # Prepare data for chart chart_data <- mock_data |> filter(level2 == input$custom_bar_click$drilled_place) |> group_by(level = level3) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = glue::glue( "Sales by City (Filtered for {input$custom_bar_click$drilled_place})" ), chart_color = "#FAC858", chart_drill_to = NULL ) } }) } shinyApp(ui, server) ``` </code></pre> Now we can drill down to the city level: <img class="alignnone size-full wp-image-17889" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01be2ebb65105dffd9233_step_8-dynamically-modifying-the-bar-charts-level-on-bar-click.gif" alt="Step 8 result of dynamically modifying the bar charts level on a bar click in the Shiny app" width="1863" height="666" /> <h3 id="go-back">Step 9: Add an Additional Observer to Go Back from the Drill</h3> Finally, to complete the drill-down functionality we actually need to be able to <strong>drill-up</strong> in order to explore how the different parent categories are composed. The trick to accomplishing this is to add a title called <strong>Back</strong> and observe it in the same way. <pre><code> ```{r, eval = FALSE} plot_sales_data <- function( chart_data, chart_title, chart_color, chart_drill_to, chart_back_drill_to, filtered_place) { sales_chart <- chart_data |> e_chart(x = level) |> e_bar(total, name = chart_title, color = chart_color) # Adding the click observer only when drill_to is passed if (!is.null(chart_drill_to)) { sales_chart <- sales_chart |> e_on( query = "series.bar", # Set input values handler = glue::glue( "function(params){ Shiny.setInputValue( 'custom_bar_click', {clicked_level: '<>', drilled_place: params.name}, {priority: 'event'} ); }", .open = "<<", .close = ">>" ), event = "click" ) } if (!is.null(chart_back_drill_to)) { if (is.null(filtered_place)) { observe_handler = glue::glue( "function(params){ Shiny.setInputValue( 'custom_bar_click', {clicked_level: '<>'}, {priority: 'event'} ); }", .open = "<<", .close = ">>" ) } else { observe_handler = glue::glue( "function(params){ Shiny.setInputValue( 'custom_bar_click', {clicked_level: '<>', drilled_place: '<>'}, {priority: 'event'} ); }", .open = "<<", .close = ">>" ) } sales_chart <- sales_chart |> e_title("Back", triggerEvent = TRUE) |> e_on( query = "title", # Set input values handler = observe_handler, event = "click" ) } return(sales_chart) } ui <- fluidPage( h1("Drill Down in Shiny"), echarts4rOutput("chart") ) # Define server server <- function(input, output) { observeEvent(input$custom_bar_click1, { print(input$custom_bar_click1) }) output$chart <- renderEcharts4r({ # Our custom input value that we send from the bar click print(input$custom_bar_click) if (is.null(input$custom_bar_click$clicked_level) || input$custom_bar_click$clicked_level == "level1") { # Prepare data for chart chart_data <- mock_data |> group_by(level = level1) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = "Sales by Continent", chart_color = "#5470C6", chart_drill_to = "level2", chart_back_drill_to = NULL, filtered_place = NULL ) } else if (input$custom_bar_click$clicked_level == "level2") { # Prepare data for chart chart_data <- mock_data |> filter(level1 == input$custom_bar_click$drilled_place) |> group_by(level = level2) |> summarise(total = sum(sales)) # Create chart plot_sales_data( chart_data = chart_data, chart_title = glue::glue( "Sales by Country (Filtered for {input$custom_bar_click$drilled_place})" ), chart_color = "#91CC75", chart_drill_to = "level3", chart_back_drill_to = "level1", filtered_place = NULL ) } else if (input$custom_bar_click$clicked_level == "level3") { # Prepare data for chart chart_data <- mock_data |> filter(level2 == input$custom_bar_click$drilled_place) |> group_by(level = level3) |> summarise(total = sum(sales)) previous_place <- mock_data |> filter(level2 == input$custom_bar_click$drilled_place) |> pull(level1) |> unique() # Create chart plot_sales_data( chart_data = chart_data, chart_title = glue::glue( "Sales by City (Filtered for {input$custom_bar_click$drilled_place})" ), chart_color = "#FAC858", chart_drill_to = NULL, chart_back_drill_to = "level2", filtered_place = previous_place ) } }) } shinyApp(ui, server) ``` </code></pre> If you run the code above, you should see the following working example of a drill-down in Shiny using echarts4r: <img class="alignnone size-full wp-image-17891" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01be4e196d46b4e1bde26_step_9-additional-observer-to-go-back-from-the-drill-for-echarts4r-in-Shiny.gif" alt="Step 9 result of additional observer to go back and drill-up in echarts4r for a Shiny drill-down chart. " width="1863" height="666" /> <h2>Summing Up echarts4r for Building a Drill-Down Chart in a Shiny App</h2> And with that, you now have an understanding of how the functionality of a drill-down chart works in the context of a Shiny application. Thanks to John Coene's echarts4r package Shiny developers like yourself can easily create charts and enhance user interaction with your data by leveraging the Echarts Javascript library. Additionally, you might explore <a href="https://github.com/JohnCoene/echarts4r.assets" target="_blank" rel="nofollow noopener">echarts4r.assets</a> and <a href="https://github.com/JohnCoene/echarts4r.maps" target="_blank" rel="nofollow noopener">echarts4r.maps</a>. <blockquote>Are you working with genomics data? <a href="https://appsilon.com/gosling-r-shiny/" target="_blank" rel="noopener">Try shiny.gosling to produce interactive genomics charts in R Shiny</a>.</blockquote>