Dynamic filtering with Vue.js and R Markdown
— JavaScript, Vue.js, R — 5 min read
R Markdown is a fantastic resource for creating static documents for users to consume your data. However, while R Markdown is extremely capable for standard use cases, it can prove limiting when your requirements are less straightforward. Vue.js is a modern JavaScript framework which can be harnessed to build feature-rich user interfaces. In this blog I’m going to walk you through my experience integrating Vue with R Markdown, and in doing so, we’ll generate a dynamic table of data controlled by user inputs that are powered by Vue.
Here’s a usable example of the rendered HTML output from the R Markdown file that we’ll be creating. As it’s a static file, its data is not live and represents the NBA stats at the time of publication, 14 February 2020. All of the source code referenced in this blog post is viewable in a corresponding GitHub repo.
Accessing Data
Firstly, we’ll need to get some data. I’ve thoroughly enjoyed watching Luka Dončić’s rise in the NBA this season, and given his prolific scoring, I figured it’d be fun to compare him amongst the top ten scorers in the 2019–20 NBA season. I’m going to use the R package ballr which provides an R interface for basketball-reference.com. From there we can convert our data to JSON format and echo it as a global JavaScript variable.
1players <- NBAPerGameStatistics(season = 2020)2top_ten_players <- players %>%3 top_n(10, pts) %>% 4 arrange(desc(pts))5json_players <- toJSON(top_ten_players, pretty=TRUE)6cat("<script> var topTen=", json_players, "</script>")
Integrating Vue with R markdown
For convenience, I’m going to source Vue.js from a Content Delivery Network (CDN) link. We can source a link for the latest Vue build via vuejs.org. Vue maintains an excellent devtools extension for Chrome and Firefox browsers, though it’s worth warning that these devtools won’t work on pages using the minified production builds of Vue. In this case, use the alternative development build from the same resource section on vuejs.org.
While R Markdown includes a markdown option for importing CSS and JavaScript files, these do not currently support external urls and only work with local filepaths. Since we’ve opted for CDN links, we’ll have to print the HTML markup explicitly. I’ve also opted to import noUiSlider as a convenient solution for the logic that’s necessary to manage a double range slider.
1cat(2 "<script src='https://cdn.jsdelivr.net/npm/vue@2.6.11'></script>",3 "<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/11.0.3/nouislider.min.css'>",4 "<script src='https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/11.0.3/nouislider.min.js'></script>"5)
We’ll be using Vue to manage four HTML elements: a table, two <select>
elements, and a range slider. Given the interlinked nature of the Vue logic, it makes more sense to include this code in its entirety at this stage. From here we can discuss the individual elements in reference to this block, rather than cherry-picking individual lines of code later and adding an unnecessary layer of complexity.
1var vue = new Vue({2 el: '#app',3 data: {4 players: topTen,5 minAgeRange: null,6 maxAgeRange: null,7 selectedDivision: '',8 selectedConference: '',9 nbaDivisions: {10 atlantic: ['BOS', 'BRK','NYK', 'PHI', 'TOR'],11 central: ['CHI', 'CLE', 'DET', 'IND', 'MIL'],12 northWest: ['DEN', 'MIN', 'OKC', 'POR', 'UTA'],13 pacific: ['GSW', 'LAC', 'LAL', 'PHO', 'SAC'],14 southEast: ['ATL', 'CHO', 'MIA', 'ORL', 'WAS'],15 southWest: ['DAL', 'HOU', 'MEM', 'NOP', 'SAS']16 },17 slider: {18 min: topTen.reduce((prev, curr) => prev.age < curr.age ? prev : curr).age,19 max: topTen.reduce((prev, curr) => prev.age > curr.age ? prev : curr).age20 } 21 },22 methods: {23 filterByAge: (minAge, maxAge, selectedDivision, selectedConference, nbaDivisions, nbaConferences) => {24 return (element) => { 25 return ( 26 element.age >= minAge && 27 element.age <= maxAge &&28 ( selectedDivision === '' || nbaDivisions[selectedDivision].includes(element.tm) ) &&29 ( selectedConference === '' || nbaConferences[selectedConference].includes(element.tm) ) 30 ); 31 } 32 },33 inputHandler(event) {34 if(event.srcElement.id === 'conference-selector') {35 this.selectedDivision = ''36 } else {37 this.selectedConference = ''38 }39 }40 },41 mounted() {42 noUiSlider.create(this.$refs.slider, {43 start: [this.slider.min, this.slider.max],44 step: 1,45 range: {46 'min': this.slider.min,47 'max': this.slider.max48 },49 pips: {50 mode: 'steps',51 density: 100 / (this.slider.max - this.slider.min + 1)52 }53 });54 this.$refs.slider.noUiSlider.on('update',(values, handle) => {55 this[handle ? 'maxAgeRange' : 'minAgeRange'] = parseInt(values[handle]);56 }); 57 },58 computed: { 59 filteredPlayers() { 60 return this.players.filter(this.filterByAge(this.minAgeRange, this.maxAgeRange, this.selectedDivision,61 this.selectedConference, this.nbaDivisions, this.nbaConferences));62 },63 nbaConferences() {64 return {65 eastern: [...this.nbaDivisions.atlantic, ...this.nbaDivisions.central, ...this.nbaDivisions.southEast],66 western: [...this.nbaDivisions.pacific, ...this.nbaDivisions.northWest, ...this.nbaDivisions.southWest]67 }68 }69 } 70})
Filtering Players based on input values
In our HTML code, we’ll be accessing Vue data from two places: the instance’s data object and via computed properties which dynamically change to reflect any related changes in the data object. In our case, we have a computed property called filteredPlayers. This array of players will change to reflect any changes made by the user via the <select>
elements or range slider, filtering the players accordingly. All we need to know at a high level is that Vue is reactive and our visualized data will respond in real time to user input. If you plan to work in-depth with Vue in future though, it’s well worth reading and understanding its reactivity system. This is valuable not just in understanding the secrets behind Vue’s magic, but also in avoiding common gotchas.
In our HTML markup we can populate a table by looping through our filteredPlayers array. It’s possible to include raw HTML markup in R Markdown files, but we must ‘cat’ any HTML elements utilizing Vue’s syntactic sugar, as R will print it as a block of raw code rather than actual HTML markup if we don’t. A glossary for this table’s column variables can be referenced via basketball-reference.com.
1<div class='row'>2 <table class="table">3 <thead>4 <tr>5 <th>Name</th>6 <th>Pos</th>7 <th>Age</th>8 <th>Tm</th>9 <th>G</th>10 <th>GS</th>11 <th>MP</th>12 <th>FG</th>13 <th>FGA%</th>14 <th>FG%</th>15 <th>PTS</th>16 </tr>17 <thead>18 <tbody>19 ```{r, results='asis', echo=FALSE, message=FALSE}20 cat(21 "<tr v-for='player in filteredPlayers'>", 22 "<td>{{ player.player }}</td>",23 "<td>{{ player.pos }}</td>",24 "<td>{{ player.age }}</td>",25 "<td>{{ player.tm }}</td>",26 "<td>{{ player.g }}</td>",27 "<td>{{ player.gs }}</td>",28 "<td>{{ player.mp }}</td>",29 "<td>{{ player.fg }}</td>",30 "<td>{{ player.fga }}</td>",31 "<td>{{ player.fgpercent }}</td>",32 "<td>{{ player.pts }}</td>",33 "</tr>"34 )35 ```36 </tbody>37 </table>38</div>
Configuring the range slider
One popular option for generating data tables in R Markdown is the DT package, which provides an R interface to a JavaScript library named DataTables. This package offers good solutions to many normal use cases, and even includes a range slider option for numeric, date, and time data. Despite this I feel that it’s a useful learning opportunity to include a custom slider, not just for scenarios where non-standard range filtering is required, but also for contexts where custom UI requirements necessitate a non-standard frontend.
The first thing that you may notice is that the slider is created within our Vue instance’s mounted() function. This function is known as a Lifecycle Hook and allows us to dictate that the code within the function will only be executed once the Vue instance has been created and mounted to our web page’s Document Object Model (DOM). Since this slider is separate from our Vue instance but relies upon it in order to work, it is important that the slider is created at this point and not earlier. Lower-level discussion of Vue.js is beyond the scope of this blog post, but it’s certainly worth understanding the Vue lifecycle if you do plan to use Vue in a project of your own.
There are a few final considerations for our range slider. In order to dynamically set the slider’s limits each time our markdown file is rendered, we can use JavaScript’s reduce() function to find the highest and lowest player ages. Finally, we can set a listener for the slider’s update events in order to update Vue’s data each time the slider’s ranges change.
Configuring the <select>
inputs
1<div class='row'>2 <div class='col-md-6'>3 ```{r, results='asis', echo=FALSE, message=FALSE}4 cat(5 "<select v-model='selectedConference' v-on:change='inputHandler($event)' id='conference-selector' class='form-control form-control-lg'>",6 "<option value=''>Filter by Conference</option>",7 "<option value='eastern'>Eastern</option>",8 "<option value='western'>Western</option>",9 "</select>"10 )11 ```12 </div>13 <div class='col-md-6'>14 ```{r, results='asis', echo=FALSE, message=FALSE}15 cat(16 "<select v-model='selectedDivision' v-on:change='inputHandler($event)' class='form-control form-control-lg'>",17 "<option value=''>Filter by Division</option>",18 "<option value='atlantic'>Atlantic</option>",19 "<option value='central'>Central</option>",20 "<option value='northWest'>North West</option>",21 "<option value='pacific'>Pacific</option>",22 "<option value='southEast'>South East</option>",23 "<option value='southWest'>South West</option>",24 "</select>"25 )26 ```27 </div>28</div>
These inputs allow the user to filter the players by their respective divisions and conferences. I’ve added a function named inputHandler to each input. This function will be called each time a HTML element’s value changes, in order to reset a <select>
element’s related Vue data if the user selects a value from its counterpart <select>
element. For example, we don’t want to be concurrently filtering for players in both the Atlantic Division and the Western Conference as that will return zero players.
Applying custom Styling
One notable benefit of R Markdown is that it utilizes the Bootstrap framework. When searching for solutions, it’s worth bearing in mind that R Markdown still uses Bootstrap 3.3 while Bootstrap itself is now on version 4.0. This is relevant to the styling we need for our template as we’ll need to put some padding under the div that houses our range slider. If we were googling for Bootstrap solutions, we’d likely come across Bootstrap 4’s native padding classes, but as these don’t exists in Bootstrap 3 we’ll have to create our own class.
1.pb-15 {2 padding-bottom: 9rem;3}
Working with the Quirks of R Markdown
Undoubtedly, R Markdown has some quirks. If you don’t run into anything while composing straightforward markdown templates, it’s likely you will while attempting to integrate external libraries into your template. I’ve already mentioned the issues with importing files from CDN links. Another to add to that list is indentation. My preference (for readability and ease of editing) was to use HTML markup for the necessary bootstrap divs and to nest these divs as I would in a normal HTML file. In my case I found it necessary to compromise on indentation as there existed cases where the indentation either resulted in divs being rendered as code, or even being omitted from the rendered HTML file altogether. A coworker of mine often jokes that programming in R can be both an art and a science. This certainly does seem the case at times when tweaking complex R Markdown files to ensure they knit as expected.
Conclusion
I’m a big believer in using the right tool for the right job. There are times to use and not to use R Markdown. If you find yourself requiring extensive front-end logic to create your desired user-interfaces, you may want to consider an alternative reporting solution to R Markdown. If you do find yourself leveraging R Markdown for a project though, I hope that this blog has helped spark some new ideas for achieving your desired results.