Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vue htmlwidget container #11

Open
timelyportfolio opened this issue Apr 30, 2021 · 6 comments
Open

vue htmlwidget container #11

timelyportfolio opened this issue Apr 30, 2021 · 6 comments

Comments

@timelyportfolio
Copy link
Collaborator

timelyportfolio commented Apr 30, 2021

reactable has an underappreciated WidgetContainer that handles htmlwidgets (see lines ). I think vueR should have a similar structure but without the tags for data and options. Here is a very rough draft example that needs significant improvement, iteration, and testing.

library(htmltools)
library(htmlwidgets)
library(shiny)
library(vueR)
library(listviewer)
library(plotly)

# handle non-standard behaviors by some widgets
get_widget_data <- function(widget) {
  as.tags(widget)[[2]]$children[[1]]
}

p <- plot_ly(palmerpenguins::penguins, x = ~bill_length_mm, y = ~body_mass_g)

tl <- tagList(
  crosstalk::crosstalkLibs(), # necessary for g2
  vueR::html_dependency_vue(minified = FALSE),
  htmlDependency(
    "htmlwidgets",
    packageVersion("htmlwidgets"), 
    src = system.file("www", package = "htmlwidgets"), 
    script = "htmlwidgets.js"
  ),
  p$dependencies,  # this is far from ideal but plotly works differently; in most cases do not need to add since *Output handles
  tags$div(
    tags$button("update data", onclick = "updateData()")
  ),
  tags$div(
    id = "app",
    tag('html-widget', list(
      jsoneditOutput("je"),
      `:x` = 'x',
      `name` = 'jsonedit' # ideally we find a way to avoid this
    )),
    tag('html-widget', list(
      plotlyOutput("pl"),
      `:x` = 'x',
      `name` = 'plotly' # ideally we find a way to avoid this
    ))
  ),
  tags$script(HTML(
    sprintf("
  Vue.component(
    'html-widget',
    {
      props: ['x', 'name'],
      template: '<div><slot></slot></div>',
      methods: {
        // Copied from HTMLWidgets code
        // Implement a vague facsimilie of jQuery's data method
        elementData: function(el, name, value) {
          if (arguments.length == 2) {
            return el['htmlwidget_data_' + name];
          } else if (arguments.length == 3) {
            el['htmlwidget_data_' + name] = value;
            return el;
          } else {
            throw new Error('Wrong number of arguments for elementData: ' +
              arguments.length);
          }
        },
        updateWidget: function() {
          var component = this;
          // use HTMLWidgets.widgets to give us a list of available htmlwidget bindings
          var widgets = HTMLWidgets.widgets;
          // assume there might be lots, so filter for the one we want
          //  in this case, we want jsonedit
          var widget = widgets.filter(function(widget){
            return widget.name === component.name
          })[0];
          
          // get our htmlwidget DOM element
          var el = this.$el.querySelector('.html-widget');
  
          var instance = this.elementData(el, 'init_result')
  
          widget.renderValue(
            el,
            this.x,
            instance
          );
        }
      },
      mounted: function() {
        if(typeof(this.x) === 'undefined' || this.x === null) { return }
        var component = this;
        // use HTMLWidgets.widgets to give us a list of available htmlwiget bindings
        var widgets = HTMLWidgets.widgets;
        // assume there might be lots, so filter for the one we want
        //  in this case, we want jsonedit
        var widget = widgets.filter(function(widget){
          return widget.name === component.name
        })[0];
        
        // get our htmlwidget DOM element
        var el = this.$el.querySelector('.html-widget');

        // get our htmlwidget instance with initialize
        var instance = widget.initialize(el);
        this.elementData(el, 'init_result', instance);
        widget.renderValue(
          el,
          this.x,
          instance
        );
      },
      // updated not working since does not watch deep
      //   but if the expectation is that data and options are replaced completely
      //   then updated will trigger
      updated: function() {
        this.updateWidget()
      },
      watch: {
        x: {
          handler: function() {console.log('updating');this.updateWidget()},
          deep: true
        }
      }
    }
  )
  
  var app = new Vue({
    el: '#app', 
    data: () => (%s)
  })
  
  function updateData() {
    app.x.data[0].y = app.x.data[0].y.map(d => Math.random())
  }
",
      get_widget_data(p)
    )
  ))
)

browsable(tl)
@timelyportfolio
Copy link
Collaborator Author

timelyportfolio commented Apr 30, 2021

Gior Test

library(htmltools)
library(htmlwidgets)
library(shiny)
library(vueR)
# remotes::install_github("JohnCoene/gior")
library(gior)
data("country_data")

gi <- country_data %>%
  gior() %>%
  g_data(from, to, value)

# handle non-standard behaviors by some widgets
get_widget_data <- function(widget) {
  as.tags(widget)[[2]]$children[[1]]
}

tl <- tagList(
  vueR::html_dependency_vue(minified = FALSE),
  htmlDependency(
    "htmlwidgets",
    packageVersion("htmlwidgets"), 
    src = system.file("www", package = "htmlwidgets"), 
    script = "htmlwidgets.js"
  ),
  tags$div(
    id = "app",
    tag('html-widget', list(
      giorOutput('gi'),
      `:x` = 'x',
      `name` = 'gior' # ideally we find a way to avoid this
    ))
  ),
  tags$script(HTML(
    sprintf("
  Vue.component(
    'html-widget',
    {
      props: ['x', 'name'],
      template: '<div><slot></slot></div>',
      methods: {
        // Copied from HTMLWidgets code
        // Implement a vague facsimilie of jQuery's data method
        elementData: function(el, name, value) {
          if (arguments.length == 2) {
            return el['htmlwidget_data_' + name];
          } else if (arguments.length == 3) {
            el['htmlwidget_data_' + name] = value;
            return el;
          } else {
            throw new Error('Wrong number of arguments for elementData: ' +
              arguments.length);
          }
        },
        updateWidget: function() {
          var component = this;
          // use HTMLWidgets.widgets to give us a list of available htmlwidget bindings
          var widgets = HTMLWidgets.widgets;
          // assume there might be lots, so filter for the one we want
          //  in this case, we want jsonedit
          var widget = widgets.filter(function(widget){
            return widget.name === component.name
          })[0];
          
          // get our htmlwidget DOM element
          var el = this.$el.querySelector('.html-widget');
  
          var instance = this.elementData(el, 'init_result')
  
          widget.renderValue(
            el,
            this.x,
            instance
          );
        }
      },
      mounted: function() {
        if(typeof(this.x) === 'undefined' || this.x === null) { return }
        var component = this;
        // use HTMLWidgets.widgets to give us a list of available htmlwiget bindings
        var widgets = HTMLWidgets.widgets;
        // assume there might be lots, so filter for the one we want
        //  in this case, we want jsonedit
        var widget = widgets.filter(function(widget){
          return widget.name === component.name
        })[0];
        
        // get our htmlwidget DOM element
        var el = this.$el.querySelector('.html-widget');

        // get our htmlwidget instance with initialize
        var instance = widget.initialize(el);
        this.elementData(el, 'init_result', instance);
        widget.renderValue(
          el,
          this.x,
          instance
        );
      },
      // updated not working since does not watch deep
      //   but if the expectation is that data and options are replaced completely
      //   then updated will trigger
      updated: function() {
        this.updateWidget()
      },
      watch: {
        x: {
          handler: function() {console.log('updating');this.updateWidget()},
          deep: true
        }
      }
    }
  )
  
  var app = new Vue({
    el: '#app', 
    data: () => (%s)
  })
",
      get_widget_data(gi)
    )
  ))
)

browsable(tl)

@FrissAnalytics
Copy link

FrissAnalytics commented May 1, 2021

Leaflet test

note: example does not update data yet.

library(htmltools)
library(htmlwidgets)
library(shiny)
library(vueR)
library(dplyr)
library(leaflet)

get_widget_data <- function(widget) { as.tags(widget)[[2]]$children[[1]] }

# functions from leaflet example
rand_lng <- function(n = 10) rnorm(n, -93.65, .01)
rand_lat <- function(n = 10) rnorm(n, 42.0285, .01)

p <- leaflet() %>% 
       addTiles() %>% 
       addCircles(rand_lng(50), rand_lat(50), radius = runif(50, 50, 150))

tl <- tagList(
  
  vueR::html_dependency_vue(minified = FALSE),
  
  htmlDependency("htmlwidgets", packageVersion("htmlwidgets"),  src = system.file("www", package = "htmlwidgets"), script = "htmlwidgets.js" ),
  
  p$dependencies,
  
  tags$div(
    tags$button("update data", onclick = "updateData()")
  ),
  
  tags$div(id = "app", tag('html-widget', list(leafletOutput("pl"), `:x` = 'x', `name` = 'leaflet' )) ),
  
  tags$script(HTML(
    sprintf("
  Vue.component(
    'html-widget',
    {
      props: ['x', 'name'],
	  
      template: '<div><slot></slot></div>',
      
	  methods: {

        elementData: function(el, name, value) {
          if (arguments.length == 2) {
            return el['htmlwidget_data_' + name];
          } else if (arguments.length == 3) {
            el['htmlwidget_data_' + name] = value;
            return el;
          } else {
            throw new Error('Wrong number of arguments for elementData: ' +
              arguments.length);
          }
        },
		
        updateWidget: function() {
		
          var component = this;
         
          var widgets = HTMLWidgets.widgets;
         
          var widget = widgets.filter(function(widget){
            return widget.name === component.name
          })[0];
          
          var el = this.$el.querySelector('.html-widget');
  
          var instance = this.elementData(el, 'init_result')
  
          widget.renderValue(
            el,
            this.x,
            instance
          );
        }
      },
	  
      mounted: function() {
	  
        var component = this;
       
        var widgets = HTMLWidgets.widgets;
      
        var widget = widgets.filter(function(widget){
          return widget.name === component.name
        })[0];
        
        var el = this.$el.querySelector('.html-widget');

        var instance = widget.initialize(el);
		
        this.elementData(el, 'init_result', instance);
        
		widget.renderValue( el, this.x, instance);
      },
      
      updated: function() {
        this.updateWidget()
      },
      watch: {
        x: {
          handler: function() {console.log('updating');this.updateWidget()},
          deep: true
        }
      }
    }
  )
  
  var app = new Vue({
    el: '#app', 
    data: () => (%s)
  })
  
  function updateData() {
	console.log('data', app)
  }
",
      get_widget_data(p)
    )
  ))
)

browsable(tl)

@timelyportfolio
Copy link
Collaborator Author

@FrissAnalytics nice to see that leaflet works. In a shiny context I'd like to see if we can take advantage of the proxy methods provided by leaflet.

@FrissAnalytics
Copy link

yep! Tried to get the leaflet data in the example above. Turned out it's a pretty complicated object with a highly non-trivial structure, unless you are familiar how the shiny leaflet implementation works.

Would be much cleaner to have access to the proxy and to manipulate the widget instance from server.R.

@timelyportfolio
Copy link
Collaborator Author

@FrissAnalytics Here is a little more complicated example with a plotly htmlwidget in Vuetify table cells. It is a mess but does prove that it can be done.

# plotly htmlwidgets in Vuetify table cells

library(htmltools)
library(vueR)
library(plotly)
library(dplyr)

iris_tbl <- iris %>%
  group_by(Species) %>%
  summarize(
    plot_data = get_widget_data(
      plot_ly(x = ~Sepal.Width, y = ~Sepal.Length, data = cur_data())
    )
  )

get_widget_data <- function(widget) { htmltools::as.tags(widget)[[2]]$children[[1]] }

tl <- tagList(
  plot_ly()$dependencies,
  htmlwidgets:::getDependency("plotly"),
  vueR::html_dependency_vue(minified = FALSE),
  tags$head(
    tags$link(href="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.min.css", rel="stylesheet"),
    tags$script(src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.js")
  ),
  tags$div(
    id = "app",
    tag(
      "v-data-table",
      list(
        `:headers` = "headers",
        `:items` = "tbl_data",
        tag(
          "template",
          list(
            `v-slot:item.plot_data` = "{ item }",
            tag(
              "html-widget",
              list(
                style = "width: 100%;",
                `:x` = "JSON.parse(item.plot_data).x",
                `name` = "plotly",
                tags$div(class="html-widget plotly", style="width:100%; height:400px;", `:id`="'cell-' + item.Species")
              )
            )
          )
        )
      )
    )
  ),
  tags$script(HTML(
"
  Vue.component(
    'html-widget',
    {
      props: ['x', 'name'],
      template: '<div class=\"html-widget\"><slot></slot></div>',
      methods: {
        // Copied from HTMLWidgets code
        // Implement a vague facsimilie of jQuery's data method
        elementData: function(el, name, value) {
          if (arguments.length == 2) {
            return el['htmlwidget_data_' + name];
          } else if (arguments.length == 3) {
            el['htmlwidget_data_' + name] = value;
            return el;
          } else {
            throw new Error('Wrong number of arguments for elementData: ' +
              arguments.length);
          }
        },
        updateWidget: function() {
          // see comments in mounted which is nearly identical except in update we do not initialize or attach
          //   initial data to the element.  we could clean this up and make one function.
          var component = this
          // use HTMLWidgets.widgets to give us a list of available htmlwidget bindings
          var widgets = window.HTMLWidgets.widgets
          // assume there might be lots, so filter for the one we want
          //  in this case, we want jsonedit
          var widget = widgets.filter(function(widget){
            return widget.name === component.name
          })[0]
          
          // get our htmlwidget DOM element
          var el = this.$el.querySelector('.html-widget');
    
          var instance = this.elementData(el, 'init_result')
    
          if(typeof(instance) === 'undefined') {
            // get our htmlwidget instance with initialize
            instance = widget.initialize(el);
            this.elementData(el, 'init_result', instance);
            widget.renderValue(
              el,
              this.x,
              instance
            )
          }
    
          widget.renderValue(
            el,
            this.x,
            instance
          )
        }
      },
      mounted: function() {
        if(typeof(this.x) === 'undefined' || this.x === null) { return }
        var component = this;
        // use HTMLWidgets.widgets to give us a list of available htmlwiget bindings
        var widgets = HTMLWidgets.widgets;
        // assume there might be lots, so filter for the one we want
        //  in this case, we want jsonedit
        var widget = widgets.filter(function(widget){
          return widget.name === component.name
        })[0];
        
        // get our htmlwidget DOM element
        var el = this.$el.querySelector('.html-widget');

        // get our htmlwidget instance with initialize
        var instance = widget.initialize(el);
        this.elementData(el, 'init_result', instance);
        widget.renderValue(
          el,
          this.x,
          instance
        );
      },
      // updated not working since does not watch deep
      //   but if the expectation is that data and options are replaced completely
      //   then updated will trigger
      updated: function() {
        this.updateWidget()
      },
      watch: {
        x: {
          handler: function() {console.log('updating');this.updateWidget()},
          //deep: true
        }
      }
    }
  )
"
  )),
  tags$script(HTML(
sprintf(
'
    const app = new Vue({

      el: "#app",
      
      vuetify: new Vuetify(),
      
      data: () => ({
        headers: %s,
        tbl_data: %s
      }),
        
    });
',
  jsonlite::toJSON(lapply(colnames(iris_tbl), function(x){list(text=x,value=x)}), auto_unbox=TRUE),
  jsonlite::toJSON(iris_tbl, auto_unbox = TRUE)
)
  ))
)

browsable(tl)

@timelyportfolio
Copy link
Collaborator Author

@FrissAnalytics I probably should have started with a simpler example and then built from there. Here is a Vuetify data table with iris data.

# plotly htmlwidgets in Vuetify table cells

library(htmltools)
library(vueR)


tl <- tagList(
  vueR::html_dependency_vue(minified = FALSE),
  tags$head(
    tags$link(href="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.min.css", rel="stylesheet"),
    tags$link( href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900", rel="stylesheet"),
    tags$link(href="https://cdn.jsdelivr.net/npm/@mdi/[email protected]/css/materialdesignicons.min.css", rel="stylesheet"),
    tags$script(src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.js")
  ),
  tags$div(
    id = "app",
    ref = "app",
    tag(
      "v-app",
      list(
        tag(
          "v-main",
          list(
            tag(
              "v-data-table",
              list(
                `:headers` = "headers",
                `:items` = "tbl_data"
              )
            )
          )
        )
      )
    )
  ),
  tags$script(HTML(
    sprintf(
      '
    const app = new Vue({

      el: "#app",
      
      vuetify: new Vuetify(),
      
      data: () => ({
        headers: %s,
        tbl_data: %s
      }),
        
    });
',
      jsonlite::toJSON(lapply(colnames(iris), function(x){list(text=toupper(x),value=x)}), auto_unbox=TRUE),
      jsonlite::toJSON(iris, auto_unbox = TRUE)
    )
  ))
)

browsable(tl)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants