This repository walks through the building of an htmlwidget around the JavaScript library Frappe Charts.
Because there are many files and moving pieces involved in this process, I’ve created a git repository that walks through the changes at each step of the process. With my notes about each step, I’ve included the SHA linked to the updates made during the step. If you’re viewing this on GitHub, those SHA hashes should be converted to links that will take you to a summary of which files changed at each step.
This introduction focuses on the mechanics of htmlwidgets. For a much more detailed summary, the HTML Widgets chapter of the R Markdown book is an excellent introduction.
Create a package for this HTML widget. We’re not going to publish this, so you can call it whatever you want
Add a dev script for notes
usethis::use_package("htmlwidgets")
usethis::use_package("htmltools")
usethis::use_package("jsonlite")
usethis::use_package("shiny")
usethis::use_package("yaml")Same process again, but this time for npm.
Open package.json and take a look
{
"name": "frappecharts",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "MIT"
}From Frappe Charts docs#installation:
We now have a dependency in package.json and there’s a
package-lock.json file.
There’s also a node_modules/ folder with
frappe-charts/ inside. Add node_modules to
.Rbuildignore and .gitignore. (BTW, you can
and are supposed to commit package-lock.json.)
usethis::use_build_ignore("node_modules")
usethis::use_build_ignore("package.json")
usethis::use_build_ignore("package-lock.json")
usethis::use_git_ignore("node_modules")This adds files in inst/htmlwidgets
inst
└── htmlwidgets
├── frappeChart.js #<< R <-> JS code
└── frappeChart.yaml #<< list of dependencies
and creates a file R/frappeChart.R with the
functions
frappeChart()frappeChartOutput() (for shiny)renderFrappeChart() (for shiny)npm to get our dependencies in the right placehtmlwidgets load dependencies in a way that’s exactly
the same as using a <script> tag in the HTML
<head>. Look at the documentation on
Frappe Charts and decide which file we should use.
Here’s the block from their docs
<script src="https://cdn.jsdelivr.net/npm/frappe-charts@1.2.4/dist/frappe-charts.min.iife.js"></script>
<!-- or -->
<script src="https://unpkg.com/frappe-charts@1.2.4/dist/frappe-charts.min.iife.js"></script>
We need to get our dependecy into a subfolder of
inst/htmlwidgets. Convention is
inst/htmlwidgets/lib/<dependency_name>. Rather than
creating the directoy and copying over, etc., we can have an
npm build script do this for us.
To avoid issues with mac/windows, we’ll add a dev dependency on cpy-cli.
Dev dependencies are node modules that are used to build a package,
rather than required for the package to work.
Then we create the folder frappe-charts under
inst/htmlwidgets/lib that will hold the Frappe Charts
JavaScript dependency. (If the library included other required files, we
would move these too.)
And then edit package.json to add a copy task. You can
define scripts that are runnable with
npm run <script-name>. For small build tasks, this is
an easy to implement build solution.
"scripts": {
"copy-js": "cpy 'node_modules/frappe-charts/dist/frappe-charts.min.iife*' inst/htmlwidgets/lib/frappe-charts/",
"build": "npm run copy-js"
}
Notice that running npm run build will also call
npm run copy-js. If we had more build tasks related to our
JavaScript dependencies, like linting or testing, we could add them as
separate scripts and have them run in the build process with
npm run <task-1> && npm run <task-2>,
etc.
Use the example in the Frappe Charts Docs.
tagList(
div(id = "chart"),
htmltools::htmlDependency(
name = "frappe-charts",
version = "1.3.0",
package = "frappeCharts",
src = "htmlwidgets/lib/frappe-charts",
script = "frappe-charts.min.iife.js",
all_files = TRUE
)
)And copy the JS into a javascript chunk.
⚠️ The dependencies won’t be found until you build/install.
If you get a path not found error
Error: path for html_dependency not found: inst/htmlwidgets/lib/frappe-charts
it’s most likely because
src = "inst/htmlwidgets/lib/frappe-charts"
should be relative to inst.
The first demo mixes chart types and we don’t want to do that. Use the example from Basic Chart.
const data = {
labels: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
datasets: [
{ name: "R", values: [18, 40, 30, 35, 8, 52, 17, -4] },
{ name: "Python", values: [30, 50, -10, 15, 18, 32, 27, 14] }
]
}Then re-create this data in an R chunk (changelog: 881c12:
data <- list(
labels = c("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"),
datasets = list(
list(name = "R", values = c(18, 40, 30, 35, 8, 52, 17, -4)),
list(name = "Python", values = c(30, 50, -10, 15, 18, 32, 27, 14))
)
)To get the data out of R and make it available in the document,
htmlwidgets embeds the data in a
<script type="application/json">...</script>
element in the page. Embed the data from the R chunk in a
<script> tag with an ID so that we can find it
later.
Change to js4shiny::html_document_js() so that we can
see the console.log() from JavaScript just like R code. And
then find the <script> tag and get it’s
.textContent.
Use JSON.parse() to turn the data into a JS object and
replace the data used in the chart (changelog:
7201e4.
Switch between data and rData and it should
be the same!
Change the values of the data in the R side to be random so that each re-run gives a new plot.
Delete the Comment out
the data in the JS side.data on the JS side (but we’ll want to see the
structure later).
Embed data in another list opts that will
carry additional options, such as title, type
and colors.
Parse the embedded <script> and pass the whole
object to frappe.Chart().
Change the colors to
#466683 (dark blue)#44bc96 (green)#d33f49 (red)#993d70 (purple)Read https://frappe.io/charts/docs/basic/trends_regions and add and test additional line options.
Goal: shaded area chart with lines only.
Make the labels one week and repeat 4 times. Generate
runif(7 * 4) random numbers.
Find and implement an option to reduce the number of labels on the x-axis.
opts <- list(
title = "My AwesomeR Chart",
type = "bar",
height = 250,
colors = c("#466683", "#44bc96"),
data = data,
axisOptions = list(xIsSeries = TRUE),
isNavigable = TRUE
)This is where you decide how much work you want to do on the R side and how much work should be done on the JavaScript side. One thing is clear though, R users should not be expected to construct the nested list data structure just to use your HTML widget. We are used to data.frames and tibbles, so these should be supported out of the box!
To practice our JavaScript skills, I’ve chosen to do most of the work in the browser. We’ll just ask our users to give us rectangular data, where the first column will provide the x axis labels and the remaining columns are series.
Sidenote: As you can tell, there’s a lot of validation that should happen, but that part is not as much fun so we’re going to pretend our users will always give us perfectly formatted data. If you do take on an htmlwidget package project, don’t skimp on this step. Having a friendly R API will have huge impact on the use of your widget.
We’ll use the babynames package for our demo dataset,
pick two names completely at random to compare.
library(dplyr)
library(babynames)
data <-
babynames %>%
filter(
name %in% c("Ruth", "August"),
year >= 1980
) %>%
group_by(year, name) %>%
summarize(n = sum(n)) %>%
ungroup() %>%
pivot_wider(year, name, values_from = n)At this point the chart won’t work, but you can use the browser dev console to find the right steps to reformat the data into the expected format.
We’ll make the strong assumption that the tibble in R should always be formatted with the columns
labelsrepl_example('reformat-r2js-data')const chartData = {labels: [], datasets: []}
// Get keys of data, assume that first entry is for labels, the rest are data
let labelColumn = Object.keys(x.data)[0]
let columns = Object.keys(x.data).slice(1)
// First column in x.data is the labels
chartData.labels = x.data[labelColumn]
// Create an appropriate object for each column, reformat data and add to chartData
columns.forEach(function(col) {
chartData.datasets.push({name: col, values: x.data[col]})
})
x.data = chartDatahtmlwidgets does, just inside a
frameworkWe now have all of the pieces of an htmlwidget, it’s
just a bit less coordinated.
htmlwidgets gives us a slightly nicer way of
specifying dependencies in
inst/htmlwidgets/frappeChart.yaml. We’ll have to update
that file.
When we started we added a div(id = "chart"). It
would be annoying to have to make sure that each id is
always unique. htmlwidgets will add this div
for us and give each one a unique id. We won’t have to write any code
for this, it just happens.
We’ll write an R function that will take input data and options
and format it into a list, like the opts we’ve been using.
Then we hand the data to htmlwidgets and it embeds it in a
<script> tag for us. It will also find that data
automatically and make it available on the JS side.
Finally, we wrote some code in JavaScript to initialize the
chart. In the same way, we’ll write some code in
inst/htmlwidgets/frappeChart.js which is where we’ll
reformat the data and options passed from the R world by htmlwidgets. We
also need to instantiate the chart object. For advanced usage, this is
also where we’ll put code that would let us update the widget in place
without having to re-render the whole chart.
To create the htmlwidget, we’re going to work through each of these pieces and put them in the right places.
FILE:
inst/htmlwidgets/frappeChart.yaml
Take the htmltools::htmlDependency() and turn it into
inst/htmlwidgets/frappeChart.yaml.
Note: keep htmlwidgets in src!
FILE: R/frappeChart.r
Add appropriate arguments to frappeChart().
Structure the arguments into x and pass ...
for the “extra bits”.
Rebuild the package, then create a new R markdown document:
js4shiny::js4shiny_doc().
Move the code loading dplyr, tidyr,
babynames and formatting the data. Then call
frappeCharts::frappeChart().
Render and open dev tools in the browser to see that it “works”. Meaning that the data and dependencies are included, but the chart won’t. Point out the random ID. Then go back and change it so we can find the element better.
FILE:
inst/htmlwidgets/frappeChart.js
The final step is to move the Javascript we wrote before into the js binding.
console.log(x), rebuild, rerenderx looks the same as our
opts from beforeel instead of #chartThe tooltips
can be formatted using the tooltipOptions property:
tooltipOptions: {
formatTooltipX: d => (d + '').toUpperCase(),
formatTooltipY: d => d + ' pts',
}
To write this in R (add to widget_demo.R)
tooltipOptions = list(
formatTooltipX = htmlwidgets::JS("d => 'Year: ' + d"),
formatTooltipY = htmlwidgets::JS("d => d + ' babies'")
)repl_example("frappe-chart-starter")repl_example("frappe-chart-basic")Create a basic Shiny app with
x = letters[1:n],
y = runif(n)frappeCharts::frappeChartOutput() linked to
frappeCharts::renderFrappeChart()
tooltipOptions to turn the runif()
into a percent.dev/shiny/app.R
Make a mistake in the spelling for formatTooltipY and
demo how hard it is for the end user to track down what’s wrong. This
points to how important it is to do the validation on the R side or to
do the extra work to make the R API friendly.
It’s also a good place to demo debug strategies for Shiny and regular widgets. Open the app in an external window, show the dev console, find the frappeCharts binding and add a breakpoint. Then reload and show how you an use the dev console there to figure things out.
Frappe Charts, like many JS libraries, includes a method for updating the widget without having to redraw the whole chart/plot/viz/etc.
In Frappe Charts, the full data update method is
where data is the data part of the initial
options object.
Let’s make this work…
prepareChartData() function from the code we
wrote for renderValue(). The goal is that this will let us
use the function in multiple places.chart
generally availableMake the created chart object available outside
renderValue()
el as
widgetwidget as attached to the divchart with
a chart() methodAdd a chart method to the widget object so that we can get to the current chart.
Demo by finding widget div and running
let c = $0.widget.chart()
c.addDataPoint(2017, [2500, 1500])Now, if nothing else, the chart object is accessible
so others can use or extend it.
Demo with app.R by just running the
repl_example("frappe-chart-basic") app again and using the
dev console to show that the update method is working.
let el = document.getElementById('chart')
el.widget.update({x: ['A', 'B', 'C', 'D'], Frequency: [1, 2, 3, 4]})Try with various values. You can increase the number of data points but you can’t add or change the series.
HTMLWidgets.shinyMode// after factory function
if (HTMLWidgets.shinyMode) {
Shiny.addCustomMessageHandler('frappeCharts:update', function({id, data}) {
let el = document.getElementById(id)
el.widget.update(data)
})
}Restructure the app code so that the chart initializes with flat data
(0.5). Use session$sendCustomMessage to trigger the
update.
Note that the JS function above takes id and
data using destructuring. It’s easy to write
function(id, data) but this won’t work because the handler
can only take one argument.
Demo the app, now updates are fast!
sendCustomMessage called
updateFrappeChart()Attach the event listener during renderValue() and watch
for the data-select event. Use the el.id to
create a new id, like el.id + '_selected'. Send back
index and values from the event.
Add verbatimTextOutput('selected') to show
input$chart_selected.
You would probably want to do some work for the user and return more meaningful values. We’ll probably just copy and paste this during the workshop, but here’s a potential method.
This function basically reverses the chart processing and and returns a list that should be a dataframe.
if (HTMLWidgets.shinyMode && x.isNavigable) {
el.addEventListener('data-select', function(ev) {
let {index, values} = ev
let chart = el.widget.chart()
let label = chart.data.labels[index]
let names = chart.data.datasets.map(d => d.name)
let data = values.reduce(function(acc, v, idx) {
acc[names[idx]] = v
return acc
}, {})
data[labelsName] = label
Shiny.setInputValue(el.id + '_selected', data)
})
}But now in Shiny it needs to go from a list to a data.frame. To do
this we use shiny::registerInputHandler() in R and give the
input event a type:
inputId_selected:frappeCharts-selected.
.onLoad <- function(libname, pkgname) {
shiny::registerInputHandler(
type = "frappeCharts-selected",
fun = function(value, session, inputName) {
as.data.frame(value, stringsAsFactors = FALSE)
}
)
}Final result: repl_example("frappe-chart-full")