JohnCoene / echarts4r

🐳 ECharts 5 for R
http://echarts4r.john-coene.com/
Other
601 stars 81 forks source link

Universal Transition (Transitioning from Plot Type to Plot Type) #440

Closed jonesworks closed 2 years ago

jonesworks commented 2 years ago

Hi John,

I love your work! Thank you. A lot. The libraries you've put together are fantastic. I've also started reading your recent book. I hope to create a widget for RoughViz.js -- a first effort of sorts...

I just read 'What's New in Apache ECharts 5.20'. Transitioning from plot type to plot type (from bar to pie, from scatter to bar, etc.) is all the rage. It's done, if I understand correctly, by way of Universal Transition.

I don't believe the question's been asked here, nor has it been asked on SO (my apologies if it has). Is there a somewhat straightforward way to implement this functionality?

Below is code from an example in Echarts docs (Morphing Transitions Across Series is the example's heading). Here is a link to relevant documentation: https://echarts.apache.org/handbook/en/basics/release-note/5-2-0/

Thanks!

const dataset = {
  dimensions: ['name', 'score'],
  source: [
    ['Hannah Krause', 314],
    ['Zhao Qian', 351],
    ['Jasmin Krause ', 287],
    ['Li Lei', 219],
    ['Karle Neumann', 253],
    ['Mia Neumann', 165],
    ['Böhm Fuchs', 318],
    ['Han Meimei', 366]
  ]
};
const pieOption = {
  dataset: [dataset],
  series: [
    {
      type: 'pie',
      // associate the series to be animated by id
      id: 'Score',
      radius: [0, '50%'],
      universalTransition: true,
      animationDurationUpdate: 1000
    }
  ]
};
const barOption = {
  dataset: [dataset],
  xAxis: {
    type: 'category'
  },
  yAxis: {},
  series: [
    {
      type: 'bar',
      // associate the series to be animated by id
      id: 'Score',
      // Each data will have a different color
      colorBy: 'data',
      encode: { x: 'name', y: 'score' },
      universalTransition: true,
      animationDurationUpdate: 1000
    }
  ]
};

option = barOption;

setInterval(() => {
  option = option === pieOption ? barOption : pieOption;
  // Use the notMerge form to remove the axes
  myChart.setOption(option, true);
}, 2000); 
JohnCoene commented 2 years ago

I want this feature in the package too but have not found a great way to bring that in yet. The issue is not so much technical but in creating a nice API.

Best I can do for now is something like below. This works but it feels like a bad API to me.

mtcars2 <- mtcars |> 
  head() |> 
  tibble::rownames_to_column("model")

e1 <- mtcars2 |> 
  e_charts(model) |> 
  e_bar(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e2 <- mtcars2 |> 
  e_charts(model) |> 
  e_pie(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

cb <- "() => {
  let x = 0;
  setInterval(() => {
    x++
    chart.setOption(opts[x % 2], true);
  }, 3000);
}"

e_morph(e1, e2, callback = cb)

Note: I just added e_morph so you'll need the Github version to make this work.

jonesworks commented 2 years ago

Awesome! Thank you so much!

JohnCoene commented 2 years ago

Maybe @rdatasculptor has some idea on how this could be improved

rdatasculptor commented 2 years ago

@JohnCoene

First of all: This is very exciting! Second: I can't seem to find documentation on the morph feature on the echarts main site. Do you know where I can find it? Edit: found it!

Third: though I think this is extremely exciting and eye candy :), I have a hard time finding real life purposes. For me it would be way more interesting if I (as a user) could control manually which charts is being shown, meaning: being able to switch manually between e.g. a pie and a bar chart using some kind of buttons. Also I am aware of the fact that this would be an additional feature compared to the echarts js functionality (?).

Fourth: regarding the API, my first thought is to separate the callback object into more than one argument to pass e_morph function. But I need think this over...

JohnCoene commented 2 years ago

On the third bit, it's doable. Instead of rotating through we can have it triggered by a button.

mtcars2 <- mtcars |> 
  head() |> 
  tibble::rownames_to_column("model")

e1 <- mtcars2 |> 
  e_charts(model) |> 
  e_bar(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e2 <- mtcars2 |> 
  e_charts(model) |> 
  e_pie(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

cb <- "() => {
  let x = 0;
  document.getElementById('toggle')
    .addEventListener('click', (e) => {
      x++
      chart.setOption(opts[x % 2], true);
    });
}"

e_morph(e1, e2, callback = cb) %>% 
  htmlwidgets::prependContent(
    htmltools::tags$button("Toggle", id = "toggle")
  )

Since it's all JavaScript we can do whatever we like on the front-end really.

For the API we can then think of convenience functions to build up those toggles, as well as convenience functions for Shiny (where buttons, etc. is more intuitive to most R users), passing a JavaScript function as a string is horrendous.

rdatasculptor commented 2 years ago

Brilliant work! This very cool, I will try out some ideas as soon as possible

rdatasculptor commented 2 years ago

@JohnCoene One idea could be to make telling a data story easier: use the transition between charts to highlight parts of the chart and tell the user why these parts are important. I know this can be done by using the timeline functionality, but e_morph() seems much easier to use for this goal.

Conceptually something like this:

mtcars2 <- mtcars |> 
  head() |> 
  tibble::rownames_to_column("model")
max <- list(
  name = "Max",
  type = "max"
)
e1 <- mtcars2 |> 
  e_charts(model) |> 
   e_bar(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )
e2 <- mtcars2 |> 
  e_charts(model) |> 
  e_title("And here is the interesting part of the chart. Take a look at this bar") |>
  e_grid(top = 100) |>
  e_legend(right = 10) |>
  e_bar(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  ) |>
  e_mark_point("carb", data = max)

e3 <- mtcars2 |> 
  e_charts(model) |> 
  e_title("And here is the interesting part of the chart. Take a look at this bar") |>
  e_grid(top = 100) |>
  e_legend(right = 10) |>
  e_pie(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

cb <- "() => {
  let x = 0;
  document.getElementById('toggle')
    .addEventListener('click', (e) => {
      x++
      chart.setOption(opts[x % 3], true);
    });
}"

e_morph(e1, e2, e3, callback = cb) %>% 
  htmlwidgets::prependContent(
    htmltools::tags$button("Toggle", id = "toggle")
  )

I noticed it is currently not yet possible to add more than two charts to the e_morph() function (so there isn't much of a data story in this example), but the idea should be clear.

In this perspective I think it would be nice to be able to give each chart it's own button. Or to make a next and previous button, or something like that.

JohnCoene commented 2 years ago

I'll take a look later, thank you. You should be able to pass as many charts as you want to e_morph, it uses ...

JohnCoene commented 2 years ago

I think it might be good for the scrollytell type? Maybe @z3tt has some ideas?

rdatasculptor commented 2 years ago

I added a third chart to my conceptual example. As you can see, the third one is not shown as one of the charts in the final transition.

JohnCoene commented 2 years ago

Yes because the JavaScript is unchanged, need to change the modulo x % 2 to x % 3

rdatasculptor commented 2 years ago

Yes... that was an easy one. Sorry for having bothered you with this one!

rdatasculptor commented 2 years ago

Okay, this is perhaps a better example of a "scrollytell" type? The content is worthless though. But I guess you get the point :)

---
title: "Scrollytelling"
output: 
  flexdashboard::flex_dashboard:
    self_contained: true
    vertical_layout: fill
    theme: lumen
---

```{r, echo=FALSE, comment=FALSE, warning=FALSE}
library(echarts4r)
library(tidyverse)
df <- Titanic
mtcars2 <- mtcars %>%
  #head() %>%
  tibble::rownames_to_column("model")
max <- list(
  name = "Max",
  type = "max"
)
min <- list(
  name = "Min",
  type = "min"
)
e01 <- mtcars2 %>% slice(1:2) %>%
  e_charts(model) %>% 
  e_title("Look at this nice circle,\ndevided into two parts.\n\nBut what is it?", top = 40) %>%
  e_grid(top = 100) %>% 
  e_x_axis(show = FALSE) %>%
  e_legend(show = FALSE, right = 10) %>%
   e_pie(
    qsec, label = list(show = FALSE), 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e1 <- mtcars2 %>% slice(1:2) %>%
  e_charts(model) %>% 
  e_title("Pie chart alert!\n\nThese are 'qsec' scores of two cars\nBut okay, two categories... We will keep the pie...\n\nBut what if we add a car?\nIs the pie still readable...?", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_pie(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e2 <- mtcars2 %>% slice(1:3) %>%
  e_charts(model) %>%
  e_title("Well I don't think so.\n\nI guess it all gets even worse if we add another car", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_pie(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e3 <- mtcars2 %>% slice(1:4) %>%
  e_charts(model) %>%
  e_title("Yes... here you go :(.\n\nFor most people the pie fails to be easy to read.\n\nLet's turn it into a rose. Is that better?", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_pie(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e4 <- mtcars2 %>% slice(1:4) %>%
  e_charts(model) %>%
  e_title("Yes, the rose type chart is way better to understand :).\n\nBut I am curious about how it looks like in a line chart.", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_pie(
    qsec, 
    roseType = TRUE,
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e5 <- mtcars2 %>% slice(1:6) %>%
  e_charts(model) %>%
  e_title("Here's the line chart!\n\nNow the silly thing is that we connect cars by lines\nwhilst there is no connection at all\nthey are still categories.\n\nlet's turn it into a bar chart", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_line(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  ) %>% e_y_axis(show = FALSE)

e6 <- mtcars2 %>% slice(1:6) %>%
  e_charts(model) %>%
  e_title("yes, that's better!\n\nWe lost the colors though\n\n(ofcourse that can be done properly in echarts4,\nbut today I am lazy)", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_bar(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  ) %>% e_y_axis(show = FALSE)
e7 <- mtcars2 %>% slice(1:6) %>%
  e_charts(model) %>%
  e_title("By the way, here are the cars with\nlowest and highest scores.\n\nLet's add more cars!\nI am wondering which ones will be the lowest and hightest", top = 40) %>%
  e_grid(top = 100) %>%
  e_legend(right = 10) %>%
   e_bar(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  ) %>%
  e_y_axis(show = FALSE) %>%
  e_mark_point(data = max) %>%
  e_mark_point(data = min)

e8 <- mtcars2 %>% slice(1:20) %>%
  e_charts(model) %>% 
  e_title("Well.. for me this a nice final chart of this data story :)\n\nbye all!", top = 40) %>%
  e_grid(top = 200, bottom = 100) %>%
  e_legend(right = 10) %>%
   e_bar(
    qsec, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  ) %>%
  e_x_axis(axisLabel = list(rotate = 30)) %>%
  e_y_axis(show = FALSE) %>%
  e_mark_point(data = max) %>%
  e_mark_point(data = min)

cb <- "() => {
  let x = 0;
  document.getElementById('toggle')
    .addEventListener('click', (e) => {
      x++
      chart.setOption(opts[x % 9], true);
    });
}"

e_morph(e01, e1, e2, e3,e4, e5, e6,e7,e8,callback = cb) %>% 
  htmlwidgets::prependContent(
    htmltools::tags$button("Continue telling!", id = "toggle")
  )
rdatasculptor commented 2 years ago

@JohnCoene if you have a short amount of time and if you please ofcourse :), would you be able to provide an example of giving every chart in the e_morph() sequence its own (radio)button? (A radio button seems appropriate, since switching to another chart means deselection of the former chart).

I did some experiments in combining timeline with e_morph(). I think it is a powerful combination to be able to both switch between subselections of the data and add (if needed by the user) extra information or proper morphs to each subselection. A button for each added-information 'morph' would make it more logical.

For me e_morph is another feature of echarts4r that makes it possible to not always needing to use shiny ,,🎉 when the data and the dashboard get complex.

JohnCoene commented 2 years ago

You can do something like this.

library(echarts4r)
library(htmltools)

mtcars2 <- mtcars |> 
  head() |> 
  tibble::rownames_to_column("model")

e1 <- mtcars2 |> 
  e_charts(model) |> 
  e_bar(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e2 <- mtcars2 |> 
  e_charts(model) |> 
  e_pie(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

cb <- "() => {
  let x = 0;
  let elements = document.getElementsByClassName('echarts-input');
  Array.from(elements).forEach(function(element) {
    element.addEventListener('change', (e) => {
      chart.setOption(opts[e.target.value -1 ], true);
    });
  }); 
}"

e_morph(e1, e2, callback = cb) %>% 
  htmlwidgets::prependContent(
    tags$div(
      class = "form-check",
      tags$input(
        class = "form-check-input echarts-input",
        type = "radio",
        name = "echarts",
        checked = NA,
        value = "1"
      ),
      tags$label(
        class = "form-check-label",
        "First chart"
      )
    ),
    tags$div(
      class = "form-check",
      tags$input(
        class = "form-check-input echarts-input",
        type = "radio",
        name = "echarts",
        value = "2"
      ),
      tags$label(
        class = "form-check-label",
        "Second chart"
      )
    )
  )
rdatasculptor commented 2 years ago

Thank you very much for your quick response and your efforts! This is exactly what I meant. this is so cool! very, very inspring.

(another note to myself: learn javascript...)

rdatasculptor commented 2 years ago

@JohnCoene Hopefully one last question. Do you think it is possible to include the radiobuttons somewhere in the echarts area? Now they push the chart partially off the screen. Maybe by putting the radiobuttons together with the chart in an extra div, or something like that. I will try some ideas

JohnCoene commented 2 years ago

I would just use some CSS.

e_morph(e1, e2, callback = cb) %>% 
  htmlwidgets::prependContent(
    div(
      style = "position:absolute;top: 1rem;right: 1rem;",
      tags$div(
        class = "form-check",
        tags$input(
          class = "form-check-input echarts-input",
          type = "radio",
          name = "echarts",
          checked = NA,
          value = "1"
        ),
        tags$label(
          class = "form-check-label",
          "First chart"
        )
      ),
      tags$div(
        class = "form-check",
        tags$input(
          class = "form-check-input echarts-input",
          type = "radio",
          name = "echarts",
          value = "2"
        ),
        tags$label(
          class = "form-check-label",
          "Second chart"
        )
      )
    )
  )
rdatasculptor commented 2 years ago

thanks! now the radio buttons don't respond andymore. but I get the idea.

playing around with e_grid did the trick as well, but that, ofcourse is an ugly solution.

JohnCoene commented 2 years ago

Ah yes, change the z-index

e_morph(e1, e2, callback = cb) %>% 
  htmlwidgets::prependContent(
    div(
      style = "position:absolute;top: 1rem;right: 1rem;z-index: 999;",
      tags$div(
        class = "form-check",
        tags$input(
          class = "form-check-input echarts-input",
          type = "radio",
          name = "echarts",
          checked = NA,
          value = "1"
        ),
        tags$label(
          class = "form-check-label",
          "First chart"
        )
      ),
      tags$div(
        class = "form-check",
        tags$input(
          class = "form-check-input echarts-input",
          type = "radio",
          name = "echarts",
          value = "2"
        ),
        tags$label(
          class = "form-check-label",
          "Second chart"
        )
      )
    )
  )
rdatasculptor commented 2 years ago

@JohnCoene you rule!

z3tt commented 2 years ago

Wow, what a great new feature! Not much to add here, the example by @rdatasculptor gives a nice overview of what can be possible. Can't wait to play around with it!

rdatasculptor commented 2 years ago

@JohnCoene just a question. Though it really works great (!), It's a little bit tricky to combine a transitioned chart with other charts using a combineWidgets() function of the manipulateWidgets package. A proper placement of the radiobuttons is hard. Is there a way to let the radiobuttons or transition buttons be a part of the chart area themselves, like the legend or timeline? I reckon that feature should be part of the echarts package intead of echarts4r?

JohnCoene commented 2 years ago

@rdatasculptor late reply again, was away on holiday last week.

It's definitely not part of echarts.js, it can be done with standard HTML. Do you have a small example for me to look at?

rdatasculptor commented 2 years ago

@JohnCoene No problem at all! I think you really well deserved this holiday :)

A small example

library(echarts4r)
library(htmltools)
library(manipulateWidget)

mtcars2 <- mtcars |> 
  head() |> 
  tibble::rownames_to_column("model")

e1 <- mtcars2 |> 
  e_charts(model) |> 
  e_bar(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

e2 <- mtcars2 |> 
  e_charts(model) |> 
  e_pie(
    carb, 
    universalTransition = TRUE,
    animationDurationUpdate = 1000L
  )

cb <- "() => {
  let x = 0;
  let elements = document.getElementsByClassName('echarts-input');
  Array.from(elements).forEach(function(element) {
    element.addEventListener('change', (e) => {
      chart.setOption(opts[e.target.value -1 ], true);
    });
  }); 
}"

plot1 <- e_morph(e1, e2, callback = cb)

df <- data.frame(
  x = seq(50),
  y = rnorm(50, 10, 3),
  z = rnorm(50, 11, 2),
  w = rnorm(50, 9, 2)
)

plot2 <- df |> 
  e_charts(x) |> 
  e_line(z) |> 
  e_area(w) |> 
  e_title("Line and area charts")

combineWidgets(plot1, plot2, nrow = 1, colsize = c(1, 2)) %>% 
  htmlwidgets::prependContent(
    div(
      style = "position:absolute;top: 1rem;right: 1rem;z-index: 999;",
      tags$div(
        class = "form-check",
        tags$input(
          class = "form-check-input echarts-input",
          type = "radio",
          name = "echarts",
          checked = NA,
          value = "1"
        ),
        tags$label(
          class = "form-check-label",
          "First chart"
        )
      ),
      tags$div(
        class = "form-check",
        tags$input(
          class = "form-check-input echarts-input",
          type = "radio",
          name = "echarts",
          value = "2"
        ),
        tags$label(
          class = "form-check-label",
          "Second chart"
        )
      )
    )
  )

The radiobuttons, obviously, appear next to the second chart instead of next to the morphing chart. It works, and I guess I can play around with relocating the radiobuttons. But I was wondering if it could be possible to add the buttons automatically in the morphing chart area, with the buttons placed relatively from the chart area and not from the entire html page.