|
|
6年前 | |
|---|---|---|
| .. | ||
| README.md | 6年前 | |
| app.R | 6年前 | |
| dev-shiny-input.Rmd | 6年前 | |
| module_typingStats.R | 6年前 | |
| typing.js | 6年前 | |
In this project, we’re going to create a typing speed app using a custom Shiny input. The app will give users typing prompts, monitor their typing speed, and use a Frappe Chart line chart to show their speed over time as they type.
Create the directory inst/shiny-input-app and add
app.R and typing.js.
usethis::use_directory("inst/shiny-input-app")
file.create("inst/shiny-input-app/app.R")
file.create("inst/shiny-input-app/typing.js")js4shiny::repl_example("shiny-typing-01")We’ll start with typical Shiny inputs.
library(shiny)
ui <- fluidPage(
textAreaInput("typing", "Type here..."),
verbatimTextOutput("debug")
)
server <- function(input, output, session) {
output$debug <- renderPrint(input$typing)
}
shinyApp(ui, server)Run this app and type in the box.
Use Shiny’s textAreaInput() to get the template for our
own typingSpeedInput()
shiny_text_input <- shiny::textAreaInput(
"INPUT", "LABEL", placeholder = "PLACEHOLDER"
)
cat(format(shiny_text_input))## <div class="form-group shiny-input-container">
## <label class="control-label" for="INPUT">LABEL</label>
## <textarea id="INPUT" class="form-control" placeholder="PLACEHOLDER"></textarea>
## </div>
typingSpeedInput <- function(inputId, label, placeholder = NULL) {
.label <- label
htmltools::withTags(
div(
class = "form-group typing-speed",
label(class = "control-label", `for` = inputId, .label),
textarea(id = inputId, class = "form-control", placeholder = placeholder)
)
)
}Two points:
Notice that I used htmltools::withTags(), which
makes it easier to write multiple tags at once. But it has the downside
of masking the label argument of
typingSpeedInput(). Hence, the first line
.label <- label.
I added .typing-speed to our parent container so
that we can find or style our custom input.
Replace the textAreaInput() with our new
typingSpeedInput() and run the app. It works the same!
Wait, why?
ui <- fluidPage(
# textAreaInput("typing", "Type here..."),
typingSpeedInput("typing", "Type here..."),
verbatimTextOutput("debug")
)typingSpeedInput()js4shiny::repl_example("shiny-typing-02")Now we can open typing.js and create a Shiny input
binding.
If you used js4shiny::snippets_install(), you have a
ShinyInputBinding snippet that provides a template for you.
Or you can copy the chunk below.
// Ref: https://shiny.rstudio.com/articles/building-inputs.html
// Ref: https://github.com/rstudio/shiny/blob/master/srcjs/input_binding.js
const bindingName = new Shiny.InputBinding();
$.extend(bindingName, {
find: function(scope) {
// Specify the selector that identifies your input. `scope` is a general
// parent of your input elements. This function should return the nodes of
// ALL of the inputs that are inside `scope`. These elements should all
// have IDs that are used as the inputId on the server side.
return scope.querySelectorAll("inputBindingSelector");
},
getValue: function(el) {
// For a particular input, this function is given the element containing
// your input. In this function, find or construct the value that will be
// returned to Shiny. The ID of `el` is used for the inputId.
// e.g: return el.value
return 'FIXME';
},
setValue: function(el, value) {
// This method is used for restoring the bookmarked state of your input
// and allows you to set the input's state without triggering reactivity.
// Basically, reverses .getValue()
// e.g.; el.value = value
console.error('bindingName.setValue() is not yet defined');
},
receiveMessage: function(el, data) {
// Given the input's container and data, update the input
// and its elements to reflect the given data.
// The messages are sent from R/Shiny via
// R> session$sendInputMessage(inputId, data)
console.error('bindingName.receiveMessage() is not yet defined');
// If you want the update to trigger reactivity, trigger a subscribed event
$(el).trigger("change")
},
subscribe: function(el, callback) {
// Listen to events on your input element. The following block listens to
// the change event, but you might want to listen to another event.
// Repeat the block for each event type you want to subscribe to.
$(el).on("change.bindingName", function(e) {
// Use callback() or callback(true).
// If using callback(true) the rate policy applies,
// for example if you need to throttle or debounce
// the values being sent back to the server.
callback();
});
},
getRatePolicy: function() {
return {
policy: 'debounce', // 'debounce', 'throttle' or 'direct' (default)
delay: 100 // milliseconds for debounce or throttle
};
},
unsubscribe: function(el) {
$(el).off(".bindingName");
}
});
Shiny.inputBindings.register(bindingName, 'pkgName.bindingName');If you use the snippet, it automatically walks you through the first pass of parts that need to be changed. If you copied the template, you need to find and replace all:
bindingName: the name of the JavaScript object with
the specifics of your Shiny input. This name isn’t user facing, but
helps Shiny keep track of which the inputs in an app.
We’ll call ours typingSpeed
inputBindingSelector: this is the CSS selector that
can be used to find the HTML element of your input. The element you find
here is the element in your input’s HTML that has the id
with the input’s inputId.
In our case, we want the textarea element that’s a
descendent of .typing-speed, so we use
change: This is the event that will be listened to
by Shiny to know when the input has updated. For our typing speed input,
we’re going to listen to the keyup event.
pkgName: if you’re writing this input as part of a
package, use your package name. It’s not critical; this just provides
some namespacing in case there’s another package that impelements an in
put with the same bindingName.
Here we use js4shiny.
To have typing.js included with our input, we use
htmltools::htmlDependency() inside our input function. This
guarantees that typing.js is loaded once per page and is
included whenever a typingSpeedInput() is created.
# I used the htmldeps snippet for this
htmltools::htmlDependency(
name = "typingSpeed",
version = "0.0.1",
src = ".",
script = "typing.js",
all_files = FALSE
)Now when you run the app, you’ll see "FIXME" as the
output of input$typing. That’s our next step.
Read through the comments of the input binding template. They explain the role of each function.
In a nutshell, as a Shiny input author, your job is to tell Shiny a few key things:
.find() — How to find your input
elements on the page
.subscribe() — What browser events
will trigger an update from your input.
The template show how to listen 'change' events, but you
may want to listen for a different event (or multiple events!), like a
button 'click' or a 'keydown' or
'keyup' event.
.getRatePolicy() — How often to
send updates back to the server.
There are three options here: 'debounce',
'throttle', and 'direct'. See the shiny
debounce documentation and my slides for more info.
.getValue() — What is the value of
your input?
This method is called whenever a subscribed event happens, and if the value of the input has changed it is reported back to Shiny. It’s up to you, though, in this function, to read the HTML to determine the current value of the input and to construct the data that’s sent back to the server.
.receiveMessage() - Let the input
receive messages from the server.
This works just like custom message handles, except that you call
shiny::sendInputMessage(inputId, data) on the server side.
This method receives the data and can be used to update the state of the
input from values sent by the server.
Ideally you would write an update<InputName>()
function that wraps shiny::sendInputMessage(). This is how
updateTextInput()
and others works.
If you want the updated values to flow through the reactive graph
(you probably do), then you need to trigger a subscribed event after you
make the changes. This calls .getValue() which sets the
input values and reports back to the server.
.setValue() — This method takes a
value sent from your input and updates your HTML to match.
This method is required to be able to restore the input from a bookmarked state. It also allows you to set the input’s value without triggering reactivity.
checkboxInput()The input binding JavaScript for checkboxInput() is
available in the Shiny
GitHub repository. Read through it and discuss what each method
does.
Now it’s time to setup our input binding. Replace FIXME
with el.value. (changelog:
85205f)
The .value of a textarea element is a
string containing the text inside the element.
Update .getValue() to
Return the number of characters the user has typed (changelog: b3958d)
Return the number of words the user has entered (changelog: e3fa48)
Create variables for both number of characters and words and return both in an object (changelog: edfb93)
You can add your own properties and methods to the input binding. As
a convention, the property or method names start with _.
Let’s add a _timing property that with initialize with
null. (changelog:
9568fd)
Inside our input binding methods, we can now access
this._timing to get the timing property for the input. (And
a new input binding is created for each input, so if there are multiple
typingSpeed inputs, we’ll automatically get the right
_timing value.)
For methods called on objects, this refers the the
parent object.
Try the repl_example("this-simple") example to see how
this works.
repl_example("this-simple")
_timingWe’re going to use this property to start timing the user’s typing. On the one hand, when they delete all the text in the text area, we want to reset the timers. On the other hand, when they start typing, we want to know when they started.
Update the .getValue() method so that whenever
this._timing is set to null and a
null value is returned to Shiny.
Similarly, when
this._timing is nullthis._timing is updated to the current time
(Date.now()) and a null value is returned.
Include the timing value in the data returned to Shiny so that you can verify it’s working.
Now you’re ready to calculate typing statistics.
Here’s the idea: each time the user presses a key, we calculate the
elapsed time in seconds since they started typing.
Then, from that value calculate:
wpm)cps)Return an object/list with
wpm,cps,time andtext in the inputHow does the app report the initial typing speed rates?
Add another if statement to continue to return
null until there are at least 3 words in the text box.
At this point, we are streaming values straight from the browser to the server. This is probably a bit much.
Change callback() to callback(true) in
subscribe().
Try various settings of
policy: debounce, throttle or
direct.Find a good delay rate.
receiveMessage()There’s not much we’d want to do from the server side in terms of updating the typing area. But maybe we’d like to be able to add a “Reset” button.
Write a method that, when it recieves a true value from
the server, clears the text input area.
Add a reset button to your app that uses
shiny$sendInputMessage() to send typing a
TRUE whenever the button is clicked.
Once you get that working, refactor it into a
resetTypingSpeed() function.
This function should take an inputId and a
session object. Use
shiny::getDefaultReactiveDomain() for the default
session value.
In our final app, we’re going to want to know on the Shiny server
side when the user has reset the typing input. We can watch for the
typing speed input to be NULL, but ultimately it’s a bit of
a hassle to turn is.null()/!is.null() into an
event.
It will be easier for us if we can simply send an
{inputId}_reset event to the server when the input has
changed.
Try adding a Shiny.setInputValue() that sends the
current time to the input ID {inputId}_reset when the
this._timing property is reset. Also, update the
debug output to monitor input$typing_reset as
well.
js4shiny::repl_example("shiny-typing-03")js4shiny::repl_example("shiny-typing-04")We’re going to drop-in our frappeChart package to add a
dynamic plot showing typing speed over time.
If you didn’t complete the frappeChart project earlier
in the workshop, you can run the code below to install the package in
the state I hope it’s in by the time we finish that section.
Our first pass is going to add a chart, but it’s not going to look super great.
To get setup, we’re going to cache the time and
wpm sent from the browser in a reactiveValues
object that we can coerce into a data.frame.
# server
wpm <- reactiveValues(time = c(), wpm = c())
observeEvent(input$typing_reset, {
wpm$time <- c()
wpm$wpm <- c()
})
observeEvent(input$typing, {
req(input$typing)
wpm$time <- c(wpm$time, input$typing$time)
wpm$wpm <- c(wpm$wpm, input$typing$wpm)
})We add the frappeCharts output to our UI
and use the following settings to render the wpm in
“real time”
# server
output$chart_typing_speed <- frappeCharts::renderFrappeChart({
frappeCharts::frappeChart(
data.frame(time = wpm$time, wpm = wpm$wpm),
type = "line",
title = "Your Typing Speed",
is_navigable = FALSE,
axisOptions = list(xIsSeries = TRUE),
lineOptions = list(regionFill = TRUE)
)
})Replace the initial frappeChart() with a simple
placeholder.
# server
output$chart_typing_speed <- frappeCharts::renderFrappeChart({
frappeCharts::frappeChart(
data.frame(time = 0, wpm = 0),
type = "line",
title = "Your Typing Speed",
is_navigable = FALSE,
axisOptions = list(xIsSeries = TRUE),
lineOptions = list(regionFill = TRUE)
)
})and use the updateFrappeChart() function to update the
chart in place.
observeEvent(wpm$time, {
frappeCharts::updateFrappeChart(
inputId = "chart_typing_speed",
data = data.frame(time = wpm$time, wpm = wpm$wpm)
)
})js4shiny::repl_example("shiny-typing-05")Download the Shiny
module I created for this project into the directory with your
app.R file.
Then source this module at the start of your app.
Add the module’s UI to your UI above the typing area.
While you’re in the UI area, fix something I missed earlier. With
bootstrap, you can set the number of
rows in the textarea. Add this argument to
typingSpeedInput and set the default value to
4.
Use the typingStats() module to calculate
wpm. Replace the wpm reactive values list and
the two observers we had before with the new typingStats()
module.
# server
wpm <- typingStats(
"typing_stats",
typing = reactive(input$typing),
typing_reset = reactive(input$typing_reset)
)And finally, use the new wpm() reactive from the module
as the data for the frappeChart update.
If you don’t have the stringdist package installed, install it now to get some extra stats.
Push the app to https://shinyapps.io!
Or check out the one I deployed: type-racer.