You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

675 lines
19KB

  1. ---
  2. output: github_document
  3. ---
  4. [shiny-debounce]: https://shiny.rstudio.com/reference/shiny/latest/debounce.html
  5. [updateText-shiny-input-binding]: https://github.com/rstudio/shiny/blob/a2a4e40821b9811a40e461f67e3622196d8aa726/srcjs/input_binding_text.js#L31-L41
  6. [checkboxInput-shiny-input-binding]: https://github.com/rstudio/shiny/blob/a2a4e40821b9811a40e461f67e3622196d8aa726/srcjs/input_binding_checkbox.js
  7. [typing-stats-module-gist]: https://gist.github.com/gadenbuie/08546fd96b96fbf810f84ccdc7b69bcc
  8. [stringdist]: https://github.com/markvanderloo/stringdist
  9. [type-racer]: https://gadenbuie.shinyapps.io/type-racer/
  10. ```{r setup, include=FALSE}
  11. knitr::opts_chunk$set(eval = FALSE)
  12. github_sha_link <- function(sha) {
  13. glue::glue("[changelog: {substr(sha, 1, 6)}](https://github.com/gadenbuie/js4shiny-frappeCharts/commit/{sha})")
  14. }
  15. get_last_sha <- function() {
  16. commit <- git2r::commits()[[1]]
  17. message(commit$summary)
  18. message(commit$sha)
  19. clipr::write_clip(glue::glue(
  20. "`r github_sha_link(\"{commit$sha}\")`"
  21. ))
  22. }
  23. ```
  24. # Building a Shiny Input
  25. In this project,
  26. we're going to create a typing speed app
  27. using a custom Shiny input.
  28. The app will give users typing prompts,
  29. monitor their typing speed,
  30. and use a Frappe Chart line chart
  31. to show their speed over time as they type.
  32. ## Setup a folder for our app inside the frappeCharts package
  33. * `r github_sha_link("113340074c3af9c2cdf46cd7787829d4ec56bfcf")`
  34. Create the directory `inst/shiny-input-app` and add `app.R` and `typing.js`.
  35. ```{r}
  36. usethis::use_directory("inst/shiny-input-app")
  37. file.create("inst/shiny-input-app/app.R")
  38. file.create("inst/shiny-input-app/typing.js")
  39. ```
  40. ## Create a basic Shiny app with a typing area
  41. * `r github_sha_link("ff3a962f7e6d16a75ebdb620aac0fdfc9949086e")`
  42. * Checkpoint: `js4shiny::repl_example("shiny-typing-01")`
  43. We'll start with typical Shiny inputs.
  44. ```r
  45. library(shiny)
  46. ui <- fluidPage(
  47. textAreaInput("typing", "Type here..."),
  48. verbatimTextOutput("debug")
  49. )
  50. server <- function(input, output, session) {
  51. output$debug <- renderPrint(input$typing)
  52. }
  53. shinyApp(ui, server)
  54. ```
  55. Run this app and type in the box.
  56. ## Create our own typingSpeedInput()
  57. * `r github_sha_link("b7108029b58635652ce87f3e1ea9a2c5a6232020")`
  58. Use Shiny's `textAreaInput()` to get the template
  59. for our own `typingSpeedInput()`
  60. ```{r, eval=TRUE}
  61. shiny_text_input <- shiny::textAreaInput(
  62. "INPUT", "LABEL", placeholder = "PLACEHOLDER"
  63. )
  64. cat(format(shiny_text_input))
  65. ```
  66. ```{r}
  67. typingSpeedInput <- function(inputId, label, placeholder = NULL) {
  68. .label <- label
  69. htmltools::withTags(
  70. div(
  71. class = "form-group typing-speed",
  72. label(class = "control-label", `for` = inputId, .label),
  73. textarea(id = inputId, class = "form-control", placeholder = placeholder)
  74. )
  75. )
  76. }
  77. ```
  78. Two points:
  79. 1. Notice that I used `htmltools::withTags()`,
  80. which makes it easier to write multiple tags at once.
  81. But it has the downside of masking the `label` argument of `typingSpeedInput()`.
  82. Hence, the first line `.label <- label`.
  83. 1. I added `.typing-speed` to our parent container so that we can
  84. find or style our custom input.
  85. Replace the `textAreaInput()` with our new `typingSpeedInput()` and run the app.
  86. It works the same!
  87. Wait, why?
  88. ```{r}
  89. ui <- fluidPage(
  90. # textAreaInput("typing", "Type here..."),
  91. typingSpeedInput("typing", "Type here..."),
  92. verbatimTextOutput("debug")
  93. )
  94. ```
  95. ## Start creating an input binding for `typingSpeedInput()`
  96. * `r github_sha_link("02adb896c50a97ebe7f9a7fef2a6f00488f9418d")`
  97. * Checkpoint: `js4shiny::repl_example("shiny-typing-02")`
  98. Now we can open `typing.js` and create a Shiny input binding.
  99. If you used `js4shiny::snippets_install()`,
  100. you have a `ShinyInputBinding` snippet that provides a template for you.
  101. Or you can copy the chunk below.
  102. <details><summary>Shiny Input Binding Template</summary>
  103. ```js
  104. // Ref: https://shiny.rstudio.com/articles/building-inputs.html
  105. // Ref: https://github.com/rstudio/shiny/blob/master/srcjs/input_binding.js
  106. const bindingName = new Shiny.InputBinding();
  107. $.extend(bindingName, {
  108. find: function(scope) {
  109. // Specify the selector that identifies your input. `scope` is a general
  110. // parent of your input elements. This function should return the nodes of
  111. // ALL of the inputs that are inside `scope`. These elements should all
  112. // have IDs that are used as the inputId on the server side.
  113. return scope.querySelectorAll("inputBindingSelector");
  114. },
  115. getValue: function(el) {
  116. // For a particular input, this function is given the element containing
  117. // your input. In this function, find or construct the value that will be
  118. // returned to Shiny. The ID of `el` is used for the inputId.
  119. // e.g: return el.value
  120. return 'FIXME';
  121. },
  122. setValue: function(el, value) {
  123. // This method is used for restoring the bookmarked state of your input
  124. // and allows you to set the input's state without triggering reactivity.
  125. // Basically, reverses .getValue()
  126. // e.g.; el.value = value
  127. console.error('bindingName.setValue() is not yet defined');
  128. },
  129. receiveMessage: function(el, data) {
  130. // Given the input's container and data, update the input
  131. // and its elements to reflect the given data.
  132. // The messages are sent from R/Shiny via
  133. // R> session$sendInputMessage(inputId, data)
  134. console.error('bindingName.receiveMessage() is not yet defined');
  135. // If you want the update to trigger reactivity, trigger a subscribed event
  136. $(el).trigger("change")
  137. },
  138. subscribe: function(el, callback) {
  139. // Listen to events on your input element. The following block listens to
  140. // the change event, but you might want to listen to another event.
  141. // Repeat the block for each event type you want to subscribe to.
  142. $(el).on("change.bindingName", function(e) {
  143. // Use callback() or callback(true).
  144. // If using callback(true) the rate policy applies,
  145. // for example if you need to throttle or debounce
  146. // the values being sent back to the server.
  147. callback();
  148. });
  149. },
  150. getRatePolicy: function() {
  151. return {
  152. policy: 'debounce', // 'debounce', 'throttle' or 'direct' (default)
  153. delay: 100 // milliseconds for debounce or throttle
  154. };
  155. },
  156. unsubscribe: function(el) {
  157. $(el).off(".bindingName");
  158. }
  159. });
  160. Shiny.inputBindings.register(bindingName, 'pkgName.bindingName');
  161. ```
  162. </details>
  163. * `r github_sha_link("6fffc2337ad475af2ab09ee571014afdbb680fb6")`
  164. If you use the snippet,
  165. it automatically walks you through
  166. the first pass of parts that need to be changed.
  167. If you copied the template,
  168. you need to find and replace all:
  169. - `bindingName`: the name of the JavaScript object with the specifics of your
  170. Shiny input. This name isn't user facing, but helps Shiny keep track of which
  171. the inputs in an app.
  172. We'll call ours `typingSpeed`
  173. - `inputBindingSelector`: this is the CSS selector that can be used to find
  174. the HTML element of your input. The element you find here is the element in
  175. your input's HTML that has the `id` with the input's `inputId`.
  176. In our case, we want the `textarea` element
  177. that's a descendent of `.typing-speed`, so we use
  178. ```css
  179. .typing-speed textarea
  180. ```
  181. - `change`: This is the event that will be listened to by Shiny to know when
  182. the input has updated. For our typing speed input, we're going to listen to
  183. the `keyup` event.
  184. - `pkgName`: if you're writing this input as part of a package, use your package
  185. name. It's not critical; this just provides some namespacing in case there's
  186. another package that impelements an in put with the same `bindingName`.
  187. Here we use `js4shiny`.
  188. ## Add a dependency to the typing input
  189. * `r github_sha_link("0be8efb83bef664f707b8716eab1d5478de933c7")`
  190. To have `typing.js` included with our input,
  191. we use `htmltools::htmlDependency()` inside our input function.
  192. This guarantees that `typing.js` is loaded once per page
  193. and is included whenever a `typingSpeedInput()` is created.
  194. ```{r}
  195. # I used the htmldeps snippet for this
  196. htmltools::htmlDependency(
  197. name = "typingSpeed",
  198. version = "0.0.1",
  199. src = ".",
  200. script = "typing.js",
  201. all_files = FALSE
  202. )
  203. ```
  204. Now when you run the app,
  205. you'll see `"FIXME"` as the output of `input$typing`.
  206. That's our next step.
  207. ## Explore the input binding
  208. Read through the comments of the input binding template.
  209. They explain the role of each function.
  210. In a nutshell,
  211. as a Shiny input author,
  212. your job is to tell Shiny a few key things:
  213. 1. **`.find()`** — How to find your input elements on the page
  214. 1. **`.subscribe()`** — What browser events will trigger an update from your input.
  215. The template show how to listen `'change'` events,
  216. but you may want to listen for a different event (or multiple events!),
  217. like a button `'click'` or a `'keydown'` or `'keyup'` event.
  218. 1. **`.getRatePolicy()`** — How often to send updates back to the server.
  219. There are three options here: `'debounce'`, `'throttle'`, and `'direct'`.
  220. See the [shiny debounce documentation][shiny-debounce] and my slides
  221. for more info.
  222. 1. **`.getValue()`** — What is the value of your input?
  223. This method is called whenever a subscribed event happens, and if the value
  224. of the input has changed it is reported back to Shiny. It's up to you,
  225. though, in this function, to read the HTML to determine the current value of
  226. the input and to construct the data that's sent back to the server.
  227. 1. **`.receiveMessage()`** - Let the input receive messages from the server.
  228. This works just like custom message handles, except that you call
  229. `shiny::sendInputMessage(inputId, data)` on the server side.
  230. This method receives the data and can be used to update the state of the
  231. input from values sent by the server.
  232. Ideally you would write an `update<InputName>()` function that wraps
  233. `shiny::sendInputMessage()`. This is how [`updateTextInput()`][updateText-shiny-input-binding] and others
  234. works.
  235. If you want the updated values to flow through the reactive graph
  236. (you probably do), then you need to trigger a subscribed event after
  237. you make the changes. This calls `.getValue()` which sets the input values
  238. and reports back to the server.
  239. 1. **`.setValue()`** — This method takes a value sent from your input and
  240. updates your HTML to match.
  241. This method is required to be able to restore the input from a bookmarked
  242. state. It also allows you to set the input's value without triggering
  243. reactivity.
  244. ### Read the input binding for `checkboxInput()`
  245. The input binding JavaScript for `checkboxInput()` is available
  246. in the [Shiny GitHub repository][checkboxInput-shiny-input-binding].
  247. Read through it and discuss what each method does.
  248. ## Setup our input binding
  249. Now it's time to setup our input binding. Replace `FIXME` with `el.value`.
  250. (`r github_sha_link("85205fbcbb93ccb506558e62f73a8f0d6a88c83c")`)
  251. ### Your Turn
  252. The `.value` of a `textarea` element
  253. is a string containing the text inside the element.
  254. Update `.getValue()` to
  255. 1. Return the number of characters the user has typed (`r github_sha_link("b3958d5e3f60fbac3a0100673696bd413a276fb4")`)
  256. 1. Return the number of words the user has entered (`r github_sha_link("e3fa485bb60853e391302289213d3cd5f0846b3e")`)
  257. 1. Create variables for both number of characters and words and return both
  258. in an object (`r github_sha_link("edfb9399eae5207cc4a3ef9b48e62c9c92230477")`)
  259. ### Tracking the timing
  260. You can add your own properties and methods to the input binding.
  261. As a convention,
  262. the property or method names start with `_`.
  263. Let's add a `_timing` property that with initialize with `null`.
  264. (`r github_sha_link("9568fdcd06fa1fe1815dc48bf6efb03f9af68b29")`)
  265. ```js
  266. $.extend(typingSpeed, {
  267. _timing: null,
  268. // ...
  269. }
  270. ```
  271. Inside our input binding methods,
  272. we can now access `this._timing`
  273. to get the timing property for the input.
  274. (And a new input binding is created for each input,
  275. so if there are multiple typingSpeed inputs,
  276. we'll automatically get the right `_timing` value.)
  277. For methods called on objects,
  278. `this` refers the the parent object.
  279. Try the `repl_example("this-simple")` example to see how this works.
  280. <details><summary>`repl_example("this-simple")`</summary>
  281. ```js
  282. const person = {
  283. name: "Christelle",
  284. sayHello: function() {
  285. console.log(`Hello, ${this.name}!`)
  286. }
  287. }
  288. person.sayHello()
  289. person.name = 'Mateo'
  290. person.sayHello()
  291. ```
  292. </details>
  293. #### Your Turn: Start `_timing`
  294. We're going to use this property to start timing the user's typing.
  295. On the one hand,
  296. when they delete all the text in the text area,
  297. we want to reset the timers.
  298. On the other hand,
  299. when they start typing,
  300. we want to know when they started.
  301. Update the `.getValue()` method so that whenever
  302. - there are no characters in the textarea
  303. `this._timing` is set to `null` and a `null` value is returned to Shiny.
  304. Similarly, when
  305. - `this._timing` is `null`
  306. - and there are any characters in the text area
  307. `this._timing` is updated to the current time (`Date.now()`)
  308. and a `null` value is returned.
  309. Include the timing value in the data returned to Shiny
  310. so that you can verify it's working.
  311. (`r github_sha_link("073186bcc9741cb2494b4b9a17042ea5716a7dd5")`)
  312. ### Your Turn: How Fast?
  313. Now you're ready to calculate typing statistics.
  314. Here's the idea:
  315. each time the user presses a key,
  316. we calculate the `elapsed` time in seconds since they started typing.
  317. Then, from that value calculate:
  318. - words per minute (`wpm`)
  319. - characters per second (`cps`)
  320. Return an object/list with
  321. - `wpm`,
  322. - `cps`,
  323. - the current timestamp as `time` and
  324. - the `text` in the input
  325. (`r github_sha_link("fa02cb2314fa58ea714002072be0f78da2c6aa82")`)
  326. ### Your Turn: Hold your horses
  327. How does the app report the initial typing speed rates?
  328. Add another `if` statement to continue to return `null`
  329. until there are at least 3 words in the text box.
  330. * `r github_sha_link("9f915337a2ab55c7e94eaa5cfa454c21bc8d72ba")`
  331. ### Your Turn: Find a rate policy balance
  332. At this point,
  333. we are streaming values straight from the browser to the server.
  334. This is probably a bit much.
  335. Change `callback()` to `callback(true)` in `subscribe()`.
  336. Try various settings of
  337. - `policy`: `debounce`, `throttle` or `direct`.
  338. Find a good delay rate.
  339. * `r github_sha_link("8519d729bca13c730528a287c3601c4df2828e4f")`
  340. ### Almost done: Implement `receiveMessage()`
  341. There's not much we'd want to do from the server side
  342. in terms of updating the typing area.
  343. But maybe we'd like to be able to add a "Reset" button.
  344. Write a method that,
  345. when it recieves a `true` value from the server,
  346. clears the text input area.
  347. Add a reset button to your app that uses `shiny$sendInputMessage()`
  348. to send `typing` a `TRUE`
  349. whenever the button is clicked.
  350. * `r github_sha_link("269e4ebdc48a66ecf7466192076c4fa259582cc6")`
  351. Once you get that working,
  352. refactor it into a `resetTypingSpeed()` function.
  353. This function should take an `inputId` and a `session` object.
  354. Use `shiny::getDefaultReactiveDomain()` for the default `session` value.
  355. * `r github_sha_link("644a8b6c2e8f3719bda89cbd7801a90401c9eadb")`
  356. ### Final Step: Extra inputs on the side
  357. In our final app,
  358. we're going to want to know on the Shiny server side
  359. when the user has reset the typing input.
  360. We can watch for the typing speed input to be `NULL`,
  361. but ultimately it's a bit of a hassle
  362. to turn `is.null()`/`!is.null()` into an event.
  363. It will be easier for us if we can simply
  364. send an `{inputId}_reset` event to the server
  365. when the input has changed.
  366. Try adding a `Shiny.setInputValue()`
  367. that sends the current time
  368. to the input ID `{inputId}_reset`
  369. when the `this._timing` property is reset.
  370. Also, update the `debug` output to monitor `input$typing_reset` as well.
  371. * `r github_sha_link("ccc35d6978dabf4a709a795f89719cc353ac72bd ")`
  372. ## Use our frappeChart widget to show speed over time
  373. * Checkpoint (completed JS): `js4shiny::repl_example("shiny-typing-03")`
  374. * Checkpoint (with frappeChart): `js4shiny::repl_example("shiny-typing-04")`
  375. ### First pass
  376. * `r github_sha_link("fe55bf588400f8586472b1050c0da1b931bad1c3")`
  377. We're going to drop-in our `frappeChart` package to add a dynamic plot
  378. showing typing speed over time.
  379. If you didn't complete the `frappeChart` project earlier in the workshop,
  380. you can run the code below to install the package
  381. in the state I hope it's in by the time we finish that section.
  382. ```{r}
  383. devtools::install_github("gadenbuie/js4shiny-frappeCharts@pkg")
  384. ```
  385. Our first pass is going to add a chart,
  386. but it's not going to look super great.
  387. To get setup,
  388. we're going to cache the `time` and `wpm` sent from the browser
  389. in a `reactiveValues` object that we can coerce into a `data.frame`.
  390. ```{r}
  391. # server
  392. wpm <- reactiveValues(time = c(), wpm = c())
  393. observeEvent(input$typing_reset, {
  394. wpm$time <- c()
  395. wpm$wpm <- c()
  396. })
  397. observeEvent(input$typing, {
  398. req(input$typing)
  399. wpm$time <- c(wpm$time, input$typing$time)
  400. wpm$wpm <- c(wpm$wpm, input$typing$wpm)
  401. })
  402. ```
  403. We add the `frappeCharts` output to our UI
  404. ```{r}
  405. # ui
  406. frappeCharts::frappeChartOutput("chart_typing_speed")
  407. ```
  408. and use the following settings to render the `wpm` in "real time"
  409. ```{r}
  410. # server
  411. output$chart_typing_speed <- frappeCharts::renderFrappeChart({
  412. frappeCharts::frappeChart(
  413. data.frame(time = wpm$time, wpm = wpm$wpm),
  414. type = "line",
  415. title = "Your Typing Speed",
  416. is_navigable = FALSE,
  417. axisOptions = list(xIsSeries = TRUE),
  418. lineOptions = list(regionFill = TRUE)
  419. )
  420. })
  421. ```
  422. ### Don't redraw: Use the update method we made for frappeCharts
  423. * `r github_sha_link("8d519b645abb990df17843f36cc418591e238aca")`
  424. Replace the initial `frappeChart()` with a simple placeholder.
  425. ```{r}
  426. # server
  427. output$chart_typing_speed <- frappeCharts::renderFrappeChart({
  428. frappeCharts::frappeChart(
  429. data.frame(time = 0, wpm = 0),
  430. type = "line",
  431. title = "Your Typing Speed",
  432. is_navigable = FALSE,
  433. axisOptions = list(xIsSeries = TRUE),
  434. lineOptions = list(regionFill = TRUE)
  435. )
  436. })
  437. ```
  438. and use the `updateFrappeChart()` function to update the chart in place.
  439. ```{r}
  440. observeEvent(wpm$time, {
  441. frappeCharts::updateFrappeChart(
  442. inputId = "chart_typing_speed",
  443. data = data.frame(time = wpm$time, wpm = wpm$wpm)
  444. )
  445. })
  446. ```
  447. ## Final Step: Make it fun
  448. * `r github_sha_link("d6fa10cb805ce8aa044987814cc22cadacb37ab3")`
  449. * Checkpoint (final app): `js4shiny::repl_example("shiny-typing-05")`
  450. Download the [Shiny module I created][typing-stats-module-gist] for this project
  451. into the directory with your `app.R` file.
  452. ```{r}
  453. download.file(
  454. "http://bit.ly/js4shiny-typing-stats-module",
  455. "module_typingStats.R"
  456. )
  457. ```
  458. Then source this module at the start of your app.
  459. ```{r}
  460. source("module_typingStats.R")
  461. ```
  462. Add the module's UI to your UI above the typing area.
  463. ```{r}
  464. # ui
  465. typingStatsUI('typing_stats')
  466. ```
  467. While you're in the UI area,
  468. fix something I missed earlier.
  469. With bootstrap,
  470. you can [set the number of rows](https://getbootstrap.com/docs/3.3/css/#textarea)
  471. in the `textarea`.
  472. Add this argument to `typingSpeedInput` and set the default value to `4`.
  473. Use the `typingStats()` module to calculate `wpm`.
  474. Replace the `wpm` reactive values list and the two observers we had before
  475. with the new `typingStats()` module.
  476. ```{r}
  477. # server
  478. wpm <- typingStats(
  479. "typing_stats",
  480. typing = reactive(input$typing),
  481. typing_reset = reactive(input$typing_reset)
  482. )
  483. ```
  484. And finally, use the new `wpm()` reactive from the module
  485. as the data for the `frappeChart` update.
  486. ```{r}
  487. observeEvent(wpm()$time, {
  488. frappeCharts::updateFrappeChart('chart_typing_speed', wpm())
  489. })
  490. ```
  491. If you don't have the [stringdist] package installed,
  492. install it now to get some extra stats.
  493. ```{r}
  494. install.packages("stringdist")
  495. ```
  496. Push the app to <https://shinyapps.io>!
  497. Or check out the one I deployed: [type-racer].