├── .Rbuildignore ├── .github └── workflows │ └── publish-site.yml ├── .gitignore ├── 2023_easy_geom_recipes.qmd ├── 800px-Ggplot2_hex_logo.png ├── DESCRIPTION ├── README.md ├── README.qmd ├── _extensions └── coatless │ └── webr │ ├── _extension.yml │ ├── qwebr-cell-elements.js │ ├── qwebr-cell-initialization.js │ ├── qwebr-compute-engine.js │ ├── qwebr-document-engine-initialization.js │ ├── qwebr-document-history.js │ ├── qwebr-document-settings.js │ ├── qwebr-document-status.js │ ├── qwebr-monaco-editor-element.js │ ├── qwebr-monaco-editor-init.html │ ├── qwebr-styling.css │ ├── qwebr-theme-switch.js │ ├── template.qmd │ ├── webr-serviceworker.js │ ├── webr-worker.js │ └── webr.lua ├── _quarto.yml ├── easy-geom-recipes.Rproj ├── focus_group_highlights.qmd ├── focus_group_script.qmd ├── geom_bar_delim.R ├── index.qmd ├── invitation_to_participate.qmd ├── recipe1means.qmd ├── recipe2coordinates-original.qmd ├── recipe2coordinates.qmd ├── recipe3residuals.qmd ├── research.qmd ├── survey_instrument.Rdata ├── survey_instrument.qmd ├── survey_results_figures ├── q05r_length_user-1.png ├── q06r_frequency-1.png ├── q07ggplot2_frequency-1.png ├── q08r_frequency-1.png ├── q09ggplot2_contexts-1.png ├── q10_previous_ext_experience-1.png ├── q11_previous_ext_attempt-1.png ├── q13_oop_experience-1.png ├── q14which_completed-1.png ├── q15number_completed-1.png ├── q16tutorial_time_taken-1.png ├── q17tutorial_length-1.png ├── q18example_clarity-1.png ├── q19examples_engaging-1.png ├── q21emotion-1.png ├── q22futureuse-1.png ├── q26longtaught-1.png └── q5usepackaged-1.png ├── survey_results_summary.qmd └── tutorial-preview.qmd /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ^.*\.Rproj$ 2 | ^\.Rproj\.user$ 3 | -------------------------------------------------------------------------------- /.github/workflows/publish-site.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main, master] 4 | release: 5 | types: [published] 6 | workflow_dispatch: {} 7 | 8 | name: doc-website 9 | 10 | jobs: 11 | demo-website: 12 | runs-on: ubuntu-latest 13 | # Only restrict concurrency for non-PR jobs 14 | concurrency: 15 | group: quarto-website-${{ github.event_name != 'pull_request' || github.run_id }} 16 | permissions: 17 | contents: read 18 | pages: write 19 | id-token: write 20 | steps: 21 | - name: "Check out repository" 22 | uses: actions/checkout@v4 23 | 24 | # To render using knitr, we need a few more setup steps... 25 | # If we didn't want the examples to use `engine: knitr`, we could 26 | # skip a few of the setup steps. 27 | - name: "Setup pandoc" 28 | uses: r-lib/actions/setup-pandoc@v2 29 | 30 | - name: "Setup R" 31 | uses: r-lib/actions/setup-r@v2 32 | 33 | - name: "Setup R dependencies for Quarto's knitr engine" 34 | uses: r-lib/actions/setup-r-dependencies@v2 35 | with: 36 | packages: 37 | deps::. 38 | any::knitr 39 | any::rmarkdown 40 | any::downlit 41 | any::xml2 42 | 43 | # Back to our regularly scheduled Quarto output 44 | - name: "Set up Quarto" 45 | uses: quarto-dev/quarto-actions/setup@v2 46 | with: 47 | version: "pre-release" 48 | 49 | 50 | # Install quarto extensions 51 | - name: "Install quarto extensions" 52 | shell: bash 53 | run: | 54 | quarto add --no-prompt coatless/quarto-webr 55 | 56 | # Render the Quarto file 57 | - name: "Render working directory" 58 | uses: quarto-dev/quarto-actions/render@v2 59 | 60 | # Upload a tar file that will work with GitHub Pages 61 | # Make sure to set a retention day to avoid running into a cap 62 | # This artifact shouldn't be required after deployment onto pages was a success. 63 | - name: Upload Pages artifact 64 | uses: actions/upload-pages-artifact@v3 65 | with: 66 | retention-days: 1 67 | 68 | # Use an Action deploy to push the artifact onto GitHub Pages 69 | # This requires the `Action` tab being structured to allow for deployment 70 | # instead of using `docs/` or the `gh-pages` branch of the repository 71 | - name: Deploy to GitHub Pages 72 | id: deployment 73 | uses: actions/deploy-pages@v4 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rproj.user 2 | .Rhistory 3 | .RData 4 | .Ruserdata 5 | survey 6 | \docs 7 | _site/* 8 | /.quarto/ 9 | survey_results_summary_files/* 10 | -------------------------------------------------------------------------------- /2023_easy_geom_recipes.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "easy geom recipes" 3 | author: "Gina Reynolds, Morgan Brown" 4 | date: "1/3/2022" 5 | format: html 6 | --- 7 | 8 | 9 | Using ggplot2 has been described as writing 'graphical poems'. But we may feel at a loss for 'words' when functions we'd like to have don't exist. The ggplot2 extension system allows us to build new 'vocabulary' for fluent expression. 10 | 11 | An exciting extension mechanism is that of inheriting from existing, more primitive geoms after performing some calculation. 12 | 13 | To get your feet wet in this world and give you a taste of patterns for geom extension, we provide three basic examples of the geoms_ that inherit from *existing* geoms (point, text, segment, etc) along with a practice exercise. With such geoms, calculation is done under the hood by the ggplot2 system. 14 | 15 | With these geom, you can write *new* graphical poems with exciting new graphical 'words'! 16 | 17 | This tutorial is intended for individuals who already have a working knowledge of the grammar of ggplot2, but may like to build a richer vocabulary for themselves. 18 | 19 | # Preview 20 | 21 | Our recipes take the form: 22 | 23 | - *Step 0. Get the job done with 'base' ggplot2.* 24 | It's a good idea to clarify what needs to happen without getting into the extension architecture 25 | - *Step 1. Write a computation function.* 26 | Wrap the necessary computation into a function that your target geom_*() function will perform. We focus on 'compute_group' computation only in this tutorial. 27 | - *Step 2. Define a ggproto object.* 28 | ggproto objects allow your extension to work together with base ggplot2 functions! You'll use the computation function from step 1 to help define it. 29 | - *Step 3. Write your geom function!* 30 | You're ready to write your function. You will incorporate the ggproto from step 2 and also define which more primitive geom (point, text, segment etc) you want other behaviors to inherit from. 31 | - *Step 4. Test/Enjoy!* 32 | Take your new geom for a spin! Check out group-wise computation behavior! 33 | 34 | Below, you'll see a completely worked example (example recipes) and then a invitation to build a related target geom_*(). 35 | 36 | --- 37 | 38 | ```{r} 39 | #| label: setup 40 | #| include: false 41 | knitr::opts_chunk$set(echo = TRUE, message = F, warning = F) 42 | ``` 43 | 44 | # Example recipe #1: `geom_point_xy_medians()` 45 | 46 | -- 47 | 48 | - This will be a point at the median of x and y 49 | 50 | ## Step 0: use base ggplot2 to get the job done 51 | 52 | ```{r} 53 | #| label: penguins 54 | library(tidyverse) 55 | library(palmerpenguins) 56 | penguins <- remove_missing(penguins) 57 | 58 | 59 | penguins_medians <- penguins %>% 60 | summarize(bill_length_mm_median = median(bill_length_mm), 61 | bill_depth_mm_median = median(bill_depth_mm)) 62 | 63 | penguins %>% 64 | ggplot() + 65 | aes(x = bill_depth_mm) + 66 | aes(y = bill_length_mm) + 67 | geom_point() + 68 | geom_point(data = penguins_medians, 69 | color = "red", size = 4, 70 | aes(x = bill_depth_mm_median, 71 | y = bill_length_mm_median)) 72 | ``` 73 | 74 | 75 | ## Step 1: computation 76 | 77 | - define computation that ggplot2 should do for you, before plotting 78 | - here it's computing a variable with labels for each observation 79 | - test that functionality Step 1.b 80 | 81 | ```{r} 82 | #| label: compute_group_xy_medians 83 | # Step 1.a 84 | compute_group_xy_medians <- function(data, scales){ # scales is used internally in ggplot2 85 | data %>% 86 | summarize(x = median(x), 87 | y = median(y)) 88 | } 89 | 90 | # Step 1.b 91 | penguins %>% 92 | rename(x = bill_depth_mm, # ggplot2 will work with 'aes' column names 93 | y = bill_length_mm) %>% # therefore rename is required to used the compute function 94 | compute_group_xy_medians() 95 | ``` 96 | 97 | 98 | ## Step 2: define ggproto 99 | 100 | Things to notice 101 | 102 | - what's the naming convention for the proto object? 103 | - which aesthetics are required as inputs? 104 | - where does the function from above go? 105 | 106 | ```{r} 107 | #| label: StatXYMedians 108 | StatXYMedians <- ggplot2::ggproto(`_class` = "StatXYMedians", 109 | `_inherit` = ggplot2::Stat, 110 | required_aes = c("x", "y"), 111 | compute_group = compute_group_xy_medians) 112 | ``` 113 | 114 | 115 | ## Step 3: define geom_* function 116 | 117 | Things to notice 118 | 119 | - Where does our work up to this point enter in? 120 | - What more primitive geom will we inherit behavior from? 121 | 122 | ```{r} 123 | #| label: geom_point_xy_medians 124 | geom_point_xy_medians <- function(mapping = NULL, data = NULL, 125 | position = "identity", na.rm = FALSE, 126 | show.legend = NA, 127 | inherit.aes = TRUE, ...) { 128 | ggplot2::layer( 129 | stat = StatXYMedians, # proto object from step 2 130 | geom = ggplot2::GeomPoint, # inherit other behavior 131 | data = data, 132 | mapping = mapping, 133 | position = position, 134 | show.legend = show.legend, 135 | inherit.aes = inherit.aes, 136 | params = list(na.rm = na.rm, ...) 137 | ) 138 | } 139 | ``` 140 | 141 | 142 | ## Step 4: Enjoy! Use your function 143 | 144 | ```{r} 145 | #| label: enjoy_penguins 146 | penguins %>% 147 | ggplot()+ 148 | aes(x = bill_depth_mm, y = bill_length_mm)+ 149 | geom_point()+ 150 | geom_point_xy_medians(color = "red") 151 | ``` 152 | 153 | ### And check out conditionality! 154 | 155 | ```{r} 156 | #| label: conditional_penguins 157 | penguins %>% 158 | ggplot()+ 159 | aes(x = bill_depth_mm, 160 | y = bill_length_mm, 161 | color = species)+ 162 | geom_point()+ 163 | geom_point_xy_medians(size = 4) 164 | ``` 165 | 166 | # Task #1: create the function `geom_point_xy_means()` 167 | 168 | Using recipe #1 as a reference, try to create the function `geom_point_xy_means()` 169 | 170 | 171 | ```{r} 172 | # step 0: use base ggplot2 173 | 174 | # step 1: write your compute_group function (and test) 175 | 176 | # step 2: write ggproto with compute_group as an input 177 | 178 | # step 3: write your geom_*() function with ggproto as an input 179 | 180 | # step 4: enjoy! 181 | 182 | 183 | ``` 184 | 185 | 186 | # Example recipe #2: `geom_label_id()` 187 | 188 | --- 189 | 190 | ## Step 0: use base ggplot2 to get the job done 191 | 192 | 193 | ```{r} 194 | #| label: cars 195 | cars %>% 196 | mutate(id_number = 1:n()) %>% 197 | ggplot() + 198 | aes(x = speed, y = dist) + 199 | geom_point() + 200 | geom_label(aes(label = id_number), 201 | hjust = 1.2) 202 | ``` 203 | 204 | --- 205 | 206 | ## Step 1: computation 207 | 208 | 209 | 210 | ```{r} 211 | #| label: compute_group_row_number 212 | # you won't use the scales argument, but ggplot will later 213 | compute_group_row_number <- function(data, scales){ 214 | 215 | data %>% 216 | # add an additional column called label 217 | # the geom we inherit from requires the label aesthetic 218 | mutate(label = 1:n()) 219 | 220 | } 221 | 222 | # step 1b test the computation function 223 | cars %>% 224 | # input must have required aesthetic inputs as columns 225 | rename(x = speed, y = dist) %>% 226 | compute_group_row_number() %>% 227 | head() 228 | ``` 229 | 230 | --- 231 | 232 | ## Step 2: define ggproto 233 | 234 | 235 | 236 | ```{r} 237 | #| label: StatRownumber 238 | StatRownumber <- ggplot2::ggproto(`_class` = "StatRownumber", 239 | `_inherit` = ggplot2::Stat, 240 | required_aes = c("x", "y"), 241 | compute_group = compute_group_row_number) 242 | ``` 243 | 244 | 245 | --- 246 | 247 | ## Step 3: define geom_* function 248 | 249 | 250 | 251 | - define the stat and geom for your layer 252 | 253 | 254 | ```{r} 255 | #| label: geom_label_row_number 256 | geom_label_row_number <- function(mapping = NULL, data = NULL, 257 | position = "identity", na.rm = FALSE, 258 | show.legend = NA, 259 | inherit.aes = TRUE, ...) { 260 | ggplot2::layer( 261 | stat = StatRownumber, # proto object from Step 2 262 | geom = ggplot2::GeomLabel, # inherit other behavior 263 | data = data, 264 | mapping = mapping, 265 | position = position, 266 | show.legend = show.legend, 267 | inherit.aes = inherit.aes, 268 | params = list(na.rm = na.rm, ...) 269 | ) 270 | } 271 | ``` 272 | 273 | 274 | 275 | 276 | 277 | --- 278 | 279 | ## Step 4: Enjoy! Use your function 280 | 281 | ```{r} 282 | #| label: enjoy_again 283 | cars %>% 284 | ggplot() + 285 | aes(x = speed, y = dist) + 286 | geom_point() + 287 | geom_label_row_number(hjust = 1.2) # function in action 288 | ``` 289 | 290 | ### And check out conditionality! 291 | 292 | ```{r} 293 | #| label: conditional_compute 294 | last_plot() + 295 | aes(color = dist > 60) # Computation is within group 296 | ``` 297 | 298 | 299 | 300 | 301 | --- 302 | 303 | # Task #2: create `geom_text_coordinates()` 304 | 305 | Using recipe #2 as a reference, can you create the function `geom_text_coordinates()`. 306 | 307 | -- 308 | 309 | - geom should label point with its coordinates '(x, y)' 310 | - geom should have behavior of geom_text (not geom_label) 311 | 312 | 313 | Hint: 314 | 315 | ```{r} 316 | paste0("(", 1, ", ",3., ")") 317 | ``` 318 | 319 | 320 | 321 | 322 | ```{r} 323 | # step 0: use base ggplot2 324 | 325 | # step 1: write your compute_group function (and test) 326 | 327 | # step 2: write ggproto with compute_group as an input 328 | 329 | # step 3: write your geom_*() function with ggproto as an input 330 | 331 | # step 4: enjoy! 332 | 333 | 334 | ``` 335 | 336 | 337 | --- 338 | 339 | # Example recipe #3: `geom_point_lm_fitted()` 340 | 341 | --- 342 | 343 | ## Step 0: use base ggplot2 to get the job done 344 | 345 | ```{r} 346 | #| label: fitted_1 347 | model <- lm(formula = bill_length_mm ~ bill_depth_mm, 348 | data = penguins) 349 | 350 | penguins_w_fitted <- penguins %>% 351 | mutate(fitted = model$fitted.values) 352 | 353 | 354 | penguins %>% 355 | ggplot() + 356 | aes(x = bill_depth_mm, y = bill_length_mm) + 357 | geom_point() + 358 | geom_smooth(method = "lm", se = F) + 359 | geom_point(data = penguins_w_fitted, 360 | aes(y = fitted), 361 | color = "blue") 362 | ``` 363 | 364 | 365 | ## Step 1: computation 366 | 367 | ```{r} 368 | #| label: fitted_2 369 | compute_group_lm_fitted<- function(data, scales){ 370 | model<-lm(formula= y ~ x, data = data) 371 | data %>% 372 | mutate(y=model$fitted.values) 373 | } 374 | 375 | # test out the function 376 | penguins %>% 377 | # rename to explicitly state the x and y inputs 378 | rename(x = bill_depth_mm, y = bill_length_mm)%>% 379 | compute_group_lm_fitted() 380 | ``` 381 | 382 | 383 | ## Step 2: define ggproto 384 | 385 | ```{r} 386 | #| label: fitted_3 387 | StatLmFitted<-ggplot2::ggproto(`_class` = "StatLmFitted", 388 | `_inherit` = ggplot2::Stat, 389 | required_aes = c("x", "y"), 390 | compute_group = compute_group_lm_fitted) 391 | ``` 392 | 393 | 394 | ## Step 3: define geom_* function 395 | 396 | 397 | ```{r} 398 | #| label: fitted_4 399 | geom_point_lm_fitted <- function(mapping = NULL, data = NULL, 400 | position = "identity", na.rm = FALSE, 401 | show.legend = NA, 402 | inherit.aes = TRUE, ...) { 403 | ggplot2::layer( 404 | stat = StatLmFitted, # proto object from step 2 405 | geom = ggplot2::GeomPoint, # inherit other behavior 406 | data = data, 407 | mapping = mapping, 408 | position = position, 409 | show.legend = show.legend, 410 | inherit.aes = inherit.aes, 411 | params = list(na.rm = na.rm, ...) 412 | ) 413 | } 414 | ``` 415 | 416 | ## Step 4: Enjoy! Use your function 417 | 418 | ```{r} 419 | #| label: fitted_5 420 | penguins %>% 421 | ggplot() + 422 | aes(x = bill_depth_mm, y = bill_length_mm) + 423 | geom_point() + 424 | geom_smooth(method="lm", se= F)+ 425 | geom_point_lm_fitted(color="blue") 426 | ``` 427 | 428 | ### And check out conditionality 429 | 430 | ```{r} 431 | #| label: fitted_6 432 | penguins %>% 433 | ggplot() + 434 | aes(x = bill_depth_mm, y = bill_length_mm) + 435 | geom_point() + 436 | geom_smooth(method="lm", se= F) + 437 | geom_point_lm_fitted() + 438 | facet_wrap(facets = vars(species)) 439 | ``` 440 | 441 | --- 442 | 443 | # Task #3 create `geom_segment_lm_residuals()` 444 | 445 | Create the function `geom_segment_lm_residuals()`. 446 | 447 | ### Hint: consider what aesthetics are required for segments. We'll give you Step 0 this time... 448 | 449 | ## Step 0: use base ggplot2 to get the job done 450 | 451 | ```{r} 452 | # step 0: use base ggplot2 453 | model <- lm(formula = bill_length_mm ~ bill_depth_mm, 454 | data = penguins) 455 | 456 | penguins_w_fitted <- penguins %>% 457 | mutate(fitted = model$fitted.values) 458 | 459 | penguins %>% 460 | ggplot() + 461 | aes(x = bill_depth_mm, y = bill_length_mm) + 462 | geom_point() + 463 | geom_smooth(method = "lm", se = F) + 464 | geom_segment(data = penguins_w_fitted, 465 | aes(yend = fitted, xend = bill_depth_mm), 466 | color = "blue") 467 | 468 | # step 1: write your compute_group function (and test) 469 | 470 | # step 2: write ggproto with compute_group as an input 471 | 472 | # step 3: write your geom_*() function with ggproto as an input 473 | 474 | # step 4: enjoy! 475 | 476 | 477 | ``` 478 | 479 | --- 480 | 481 | Not interested in writing your own geoms? 482 | 483 | Check out some ready-to-go geoms that might be of interest in the ggxmean package... or other extension packages. 484 | 485 | Interested in working a bit more with geoms and making them available to more folks, but not interested in writing your own package? 486 | 487 | Join in on the development and validation of the ggxmean package for statistical educators and everyday analysis! 488 | 489 | -------------------------------------------------------------------------------- /800px-Ggplot2_hex_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvaMaeRey/easy-geom-recipes/6c602788a66aa694054295f715aa7d27e9a95bae/800px-Ggplot2_hex_logo.png -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Package: easygeomrecipes 2 | Title: Easy Geom Recipes 3 | Version: 0.1.0 4 | Authors@R: c(person("First", "Last", "email@example.com", c("aut", "cre", "cph"))) 5 | Depends: R (>= 4.4) 6 | URL: https://github.com/EvaMaeRey/easy-geom-recipes 7 | Imports: 8 | tidyverse, 9 | palmerpenguins, 10 | gapminder, 11 | sf, 12 | packcircles, 13 | ggstamp 14 | Remotes: 15 | github::EvaMaeRey/ggstamp 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | # Easy geom recipes: a new entry point to ggplot2 extension for statistical educators and their students 6 | 7 | 8 | 9 | 10 | 11 | 12 | # Study material 13 | 14 | - [survey_results_summary.html](https://evamaerey.github.io/easy-geom-recipes/survey_results_summary.html) 15 | - [survey_instrument.html](https://evamaerey.github.io/easy-geom-recipes/survey_instrument.html) 16 | - [invitation_to_participate.html](https://evamaerey.github.io/easy-geom-recipes/invitation_to_participate.html) 17 | - [index.html](https://evamaerey.github.io/easy-geom-recipes/index.html) 18 | - [focus_group_script.html](https://evamaerey.github.io/easy-geom-recipes/focus_group_script.html) 19 | - [focus_group_highlights.html](https://evamaerey.github.io/easy-geom-recipes/focus_group_highlights.html) 20 | - [easy_geom_recipes_compute_panel.html](https://evamaerey.github.io/easy-geom-recipes/easy_geom_recipes_compute_panel.html) 21 | - [easy_geom_recipes_compute_group.html](https://evamaerey.github.io/easy-geom-recipes/easy_geom_recipes_compute_group.html) 22 | - [easy_geom_recipes.html](https://evamaerey.github.io/easy-geom-recipes/easy_geom_recipes.html) 23 | - [about.html](https://evamaerey.github.io/easy-geom-recipes/about.html) 24 | - [2023_easy_geom_recipes.html](https://evamaerey.github.io/easy-geom-recipes/2023_easy_geom_recipes.html) 25 | 26 | ### Abstract 27 | 28 | This paper introduces a new introductory tutorial in ggplot2 extension. 29 | The tutorial explores six layer extensions in a step-by-step fashion. 30 | Three of the extensions are fully-worked examples. After each of the 31 | worked examples, the tutorial prompts the learner to work through a 32 | similar extension. 33 | 34 | The tutorial was evaluated by statistics and data analytics educators 35 | with substantial R and ggplot2 experience, but without much extension 36 | experience. 37 | 38 | In general, the tutorial was found to be engaging and appropriate in 39 | length. The level was judged to be accessible, such that instructors 40 | would feel comfortable sharing with advanced students. 41 | 42 | ### Narrative 43 | 44 | Using ggplot2 has been described as writing ‘graphical poems’. But we 45 | may feel at a loss for ‘words’ when functions we’d like to have don’t 46 | exist. The ggplot2 extension system allows us to build new ‘vocabulary’ 47 | for fluent expression. 48 | 49 | An exciting extension mechanism is that of inheriting from existing, 50 | more primitive geoms after performing some calculation. 51 | 52 | To get feet wet in this world and provide a taste of patterns for geom 53 | extension, ‘easy geom recipes’ provide three basic examples of the 54 | geoms\_ that inherit from *existing* geoms (point, text, segment, etc) 55 | along with a practice exercise: 56 | https://evamaerey.github.io/easy-geom-recipes/easy_geom_recipes_compute_group.html. 57 | With such geoms, calculation is done under the hood by the ggplot2 58 | system. We’ll see how to define a ggproto object; The tutorial keeps 59 | things simple by only defining computation at the compute_group stage. 60 | 61 | With new geoms, you can write *new* graphical poems with exciting new 62 | graphical ‘words’! 63 | 64 | The tutorial is intended for individuals who already have a working 65 | knowledge of the grammar of ggplot2, but may like to build a richer 66 | vocabulary for themselves. 67 | 68 | ### Motivation 69 | 70 | - ggplot2 extension underutilized 71 | 72 | - inaccessibility to demographic that might really benefit 73 | 74 | - statistical storytelling benefits from greater fluidity that new geoms 75 | afford 76 | 77 | - traditional entry point (themes) might not be compelling to an 78 | audience that might be really productive in extension! 79 | 80 | - repetition and numerous examples useful in pedegogical materials 81 | 82 | ### Tutorial form 83 | 84 | The introductory tutorial took the form of three examples where a 85 | geom\_\* is successfully created in a step-by-step fashion. After each 86 | example, the learner is prompted to create an additional geom that is 87 | computationally similar to the example. 88 | 89 | Our recipes take the form: 90 | 91 | - Step 0. Get the job done with ‘base’ ggplot2. It’s a good idea to 92 | clarify what needs to happen without getting into the extension 93 | architecture 94 | - Step 1. Write a computation function. Wrap the necessary computation 95 | into a function that your target geom\_\*() function will perform. We 96 | focus on ‘compute_group’ computation only in this tutorial. 97 | - Step 2. Define a ggproto object. ggproto objects allow your extension 98 | to work together with base ggplot2 functions! You’ll use the 99 | computation function from step 1 to help define it. 100 | - Step 3. Write your geom function! You’re ready to write your function. 101 | You will incorporate the ggproto from step 2 and also define which 102 | more primitive geom (point, text, segment etc) you want other 103 | behaviors to inherit from. 104 | - Step 4. Test/Enjoy! Take your new geom for a spin! Check out 105 | group-wise computation behavior! 106 | 107 | ## Evaluation 108 | 109 | To test the tutorial, we approached statistics and data analytics 110 | educators that we believed would have substantial experience with R and 111 | ggplot2 but not necessarily with ggplot2 extension. The study includes 112 | nine participants that completed the study and responded to a survey 113 | about the tutorial. Eight of the participants were also able to 114 | participate in focus group discussions following their completion of the 115 | the tutorial and survey. 116 | 117 | ### Participant profiles 118 | 119 | Recruited participants generally had substantial experience teaching 120 | data analytics. 121 | 122 | ![](survey_results_summary_files/figure-html/unnamed-chunk-7-1.png) 123 | 124 | Study participants all had substantial experience. All had at least five 125 | years programming in the R statistical language, with the majority 126 | having more than ten years of experience. 127 | 128 | hello 131 | 132 | Most of the participants identified as frequent users of R, using the 133 | language almost every day. 134 | 135 | ![hello](survey_results_summary_files/figure-html/q06r_frequency-1.png) 136 | 137 | Furthermore, most of the participants (7 of 9) responded that they use 138 | ggplot2 several times a week or more. 139 | 140 | ![hello](survey_results_summary_files/figure-html/q07ggplot2_frequency-1.png) 141 | 142 | With respect to writing functions, most of the group had experience 143 | writing functions, though the frequency was not as great as with using R 144 | and ggplot2. 145 | 146 | ![](survey_results_summary_files/figure-html/q08r_frequency-1.png) 147 | 148 | The respondents use ggplot2 in a variety of contexts; notably they all 149 | use it in academic research and eight of nine used it in teaching. 150 | 151 | ![](survey_results_summary_files/figure-html/q09ggplot2_contexts-1.png) 152 | 153 | However, participants in general had little or no with writing ggplot2 154 | extensions. Seven of the nine participants were aware of extension 155 | packages but had not attempted to write their own extension. One 156 | participant had written ggplot2 extensions prior to the tutorial. 157 | 158 | ![](survey_results_summary_files/figure-html/q10_previous_ext_experience-1.png) 159 | 160 | The following figure shows attempts and successes or failures in the 161 | different ggplot2 extension areas. 162 | 163 | ![](survey_results_summary_files/figure-html/q11_previous_ext_attempt-1.png) 164 | 165 | The participants had a variety of experiences with object oriented 166 | programming, but the majority had no experience with object oriented 167 | programming in R. (confirm this w/ underlying data because looking at 168 | the summary figure, we can’t 100% confirm this. But I think it’s true.) 169 | 170 | ![](survey_results_summary_files/figure-html/q13_oop_experience-1.png) 171 | 172 | # Tutorial assessment 173 | 174 | Most participants indicated the tutorial taking them a short amount of 175 | time. Six of nine said that on average, each of the recipes took less 176 | than 15 minutes to complete. The remaining three participants responded 177 | that the recipes took between 15 and 30 minutes on average. 178 | 179 | ![](survey_results_summary_files/figure-html/q16tutorial_time_taken-1.png) 180 | 181 | The first prompt `geom_point_xy_means` exercise was completed by all 182 | participants; and all but one completed the `geom_text_coordinates()` 183 | exercise. Several participants failed complete the last recipe 184 | (residuals). 185 | 186 | ![](survey_results_summary_files/figure-html/q14which_completed-1.png) 187 | 188 | ![](survey_results_summary_files/figure-html/q17tutorial_length-1.png) 189 | 190 | ![](survey_results_summary_files/figure-html/q18example_clarity-1.png) 191 | 192 | ![](survey_results_summary_files/figure-html/q19examples_engaging-1.png) 193 | 194 | ![](survey_results_summary_files/figure-html/unnamed-chunk-4-1.png) 195 | 196 | ![](survey_results_summary_files/figure-html/unnamed-chunk-5-1.png) 197 | 198 | ------------------------------------------------------------------------ 199 | 200 | ![](survey_results_summary_files/figure-html/unnamed-chunk-6-1.png) 201 | 202 | ### Focus group highlights… 203 | 204 | #### mechanics 205 | 206 | > ’… going through this was super helpful cuz now I like understand the 207 | > mechanics of it all. 208 | 209 | > ‘And so I don’t have any intentions of like making formal geoms on my 210 | > own for anything yet. But it was like really helpful for understanding 211 | > how the whole system works.’ 212 | 213 | #### failure w/ previous attempts 214 | 215 | > ‘So like there’s some other layer of getting into the ggplot extension 216 | > world that I \[was\] missing.’ 217 | 218 | #### Step by step and step 0 219 | 220 | > ’So pedagogically, I liked how it was. I like the the general like 221 | > steps like start with like make the GEOM manually with regular ggplot 222 | > and Step 0 just to have like a baseline and then going to each of the 223 | > steps to get there and then being able to compare with the original 224 | > like as far as like pedagogically, that was super helpful. Just as as 225 | > an approach to to get it right just so you can have a goal and see how 226 | > all of these, these different primitives and proto elements and 227 | > whatever fit together… in such done that really helpful. 228 | 229 | # Skepticism 230 | 231 | > And it was that easy. And I felt empowered as a result of that…. But 232 | > you know, like, my problem isn’t gonna be that easy. 233 | 234 | # Concern - missing values 235 | 236 | > When, like place where you might have an opportunity to do a little 237 | > bit of pedagogical caution, it’s with like missing values. So when 238 | > computing a mean like ggplot says, by the way, there were three rows I 239 | > didn’t plot…. So then you need to explicitly override the default and 240 | > that something like that so that people are not just blindly putting 241 | > summaries down without considering the data that are being used to 242 | > make them. 243 | 244 | # Accessibility for students 245 | 246 | > I’m teaching, so I’m teaching data visualization this summer online 247 | > again with my regular like online classes I’ve assigning \[the 248 | > tutorial\]… as kind of like an extra credit thing at the end of 249 | > semester to saying like, if you’re interested, go through this thing 250 | > and you get 10 bonus points or something just for the more advanced 251 | > students that will be in the class that will be interested. *But I 252 | > think it’s totally accessible for them.* 253 | 254 | # Higher level objectives 255 | 256 | > I’ll just add that I I think we, I could definitely use materials like 257 | > this and it did raise for me the points since I didn’t have a lot of 258 | > experience writing extensions before like it was ended up being very 259 | > comprehensible to me and so it kind of made me think that in like a 260 | > data visuals, data visualization classes that we teach, we probably 261 | > need to. There’s a balance between teaching the students to use the 262 | > tools that exist right now to like, just do your analysis and just do 263 | > the best with the tools that are available. But we probably should 264 | > include a couple of weeks on like. You know, programming and writing 265 | > extensions along these lines, because it’s obviously like very 266 | > powerful and they need to have at least some exposure to it. So you 267 | > know, a week or two weeks of materials kind of like this would be 268 | > helpful and would help distinguish them from like being able to just 269 | > work through tutorials on your own online. Like, if they could write 270 | > their own extension, that’s like real value added, you know, to their 271 | > organization. So it it made me think that I need to think about my 272 | > data visualization class A little bit more as a programming class in 273 | > some ways. And I thought that would be a good. You know, this is 274 | > pretty good material along those lines. 275 | 276 | # Relationship to writing functions 277 | 278 | > Trying to clarify a little bit more when it’s Useful to have Your own 279 | > costume Geom, as opposed to your own function 280 | 281 | ### Appendix, example exercise 282 | 283 | For clarity, I include one of the three exercises in the ‘easy geom 284 | recipes’ extension tutorial. First an ‘example recipe’ `geom_label_id()` 285 | is provided, with the step 0-4 guideposts. Then, the student is prompted 286 | to create `geom_text_coordinates()`. 287 | 288 | ```` default 289 | 290 | # Example recipe #2: `geom_label_id()` 291 | 292 | --- 293 | 294 | ## Step 0: use base ggplot2 to get the job done 295 | 296 | ```{r cars} 297 | # step 0.a 298 | cars %>% 299 | mutate(id_number = 1:n()) %>% 300 | ggplot() + 301 | aes(x = speed, y = dist) + 302 | geom_point() + 303 | geom_label(aes(label = id_number), 304 | hjust = 1.2) 305 | 306 | # step 0.b 307 | layer_data(last_plot(), i = 2) %>% 308 | head() 309 | ``` 310 | 311 | --- 312 | 313 | ## Step 1: computation 314 | 315 | ```{r compute_group_row_number} 316 | # you won't use the scales argument, but ggplot will later 317 | compute_group_row_number <- function(data, scales){ 318 | 319 | data %>% 320 | # add an additional column called label 321 | # the geom we inherit from requires the label aesthetic 322 | mutate(label = 1:n()) 323 | 324 | } 325 | 326 | # step 1b test the computation function 327 | cars %>% 328 | # input must have required aesthetic inputs as columns 329 | rename(x = speed, y = dist) %>% 330 | compute_group_row_number() %>% 331 | head() 332 | ``` 333 | 334 | --- 335 | 336 | ## Step 2: define ggproto 337 | 338 | ```{r StatRownumber} 339 | StatRownumber <- ggplot2::ggproto(`_class` = "StatRownumber", 340 | `_inherit` = ggplot2::Stat, 341 | required_aes = c("x", "y"), 342 | compute_group = compute_group_row_number) 343 | ``` 344 | 345 | 346 | --- 347 | 348 | ## Step 3: define geom_* function 349 | 350 | 351 | 352 | - define the stat and geom for your layer 353 | 354 | ```{r geom_label_row_number} 355 | geom_label_row_number <- function(mapping = NULL, data = NULL, 356 | position = "identity", na.rm = FALSE, 357 | show.legend = NA, 358 | inherit.aes = TRUE, ...) { 359 | ggplot2::layer( 360 | stat = StatRownumber, # proto object from Step 2 361 | geom = ggplot2::GeomLabel, # inherit other behavior, this time Label 362 | data = data, 363 | mapping = mapping, 364 | position = position, 365 | show.legend = show.legend, 366 | inherit.aes = inherit.aes, 367 | params = list(na.rm = na.rm, ...) 368 | ) 369 | } 370 | ``` 371 | 372 | 373 | 374 | 375 | 376 | --- 377 | 378 | ## Step 4: Enjoy! Use your function 379 | 380 | ```{r enjoy_again} 381 | cars %>% 382 | ggplot() + 383 | aes(x = speed, y = dist) + 384 | geom_point() + 385 | geom_label_row_number(hjust = 1.2) # function in action 386 | ``` 387 | 388 | ### And check out conditionality! 389 | 390 | ```{r conditional_compute} 391 | last_plot() + 392 | aes(color = dist > 60) # Computation is within group 393 | ``` 394 | 395 | 396 | 397 | 398 | --- 399 | 400 | # Task #2: create `geom_text_coordinates()` 401 | 402 | Using recipe #2 as a reference, can you create the function `geom_text_coordinates()`. 403 | 404 | -- 405 | 406 | - geom should label point with its coordinates '(x, y)' 407 | - geom should have behavior of geom_text (not geom_label) 408 | 409 | 410 | Hint: 411 | 412 | ```{r} 413 | paste0("(", 1, ", ",3., ")") 414 | ``` 415 | 416 | ```{r} 417 | # step 0: use base ggplot2 418 | 419 | # step 1: write your compute_group function (and test) 420 | 421 | # step 2: write ggproto with compute_group as an input 422 | 423 | # step 3: write your geom_*() function with ggproto as an input 424 | 425 | # step 4: enjoy! 426 | 427 | ``` 428 | ```` 429 | 430 | Thanks to Claus Wilke, June Choe, Teun Van der Brand, Isabella 431 | Velasquez, Cosima Meyer, and Eric Reder for pre-testing and reviewing 432 | the tutorial and providing useful feedback. 433 | -------------------------------------------------------------------------------- /README.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | format: gfm 3 | --- 4 | 5 | 6 | 7 | ```{r, include = FALSE} 8 | knitr::opts_chunk$set( 9 | collapse = TRUE, 10 | comment = "#>" 11 | ) 12 | ``` 13 | 14 | # Easy geom recipes: a new entry point to ggplot2 extension for statistical educators and their students 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ```{r} 24 | #| include: false 25 | knitr::opts_chunk$set( 26 | collapse = TRUE, 27 | warning = FALSE, 28 | comment = "#>" 29 | ) 30 | 31 | ``` 32 | 33 | # Study material 34 | 35 | ```{r} 36 | #| label: list-urls 37 | #| results: asis 38 | #| echo: false 39 | 40 | library(magrittr) 41 | webpages <- fs::dir_ls(path = "_site", type = "file", recurse = TRUE, glob = "*.html") %>% rev() 42 | 43 | webpages %>% 44 | basename(.) %>% 45 | paste0("- [", 46 | . , 47 | "]", 48 | "(https://evamaerey.github.io/easy-geom-recipes/", ., ")\n") %>% 49 | cat() 50 | ``` 51 | 52 | 53 | ### Abstract 54 | 55 | This paper introduces a new introductory tutorial in ggplot2 extension. The tutorial explores six layer extensions in a step-by-step fashion. Three of the extensions are fully-worked examples. After each of the worked examples, the tutorial prompts the learner to work through a similar extension. 56 | 57 | The tutorial was evaluated by statistics and data analytics educators with substantial R and ggplot2 experience, but without much extension experience. 58 | 59 | In general, the tutorial was found to be engaging and appropriate in length. The level was judged to be accessible, such that instructors would feel comfortable sharing with advanced students. 60 | 61 | 62 | ### Narrative 63 | 64 | 65 | Using ggplot2 has been described as writing 'graphical poems'. But we may feel at a loss for 'words' when functions we'd like to have don't exist. The ggplot2 extension system allows us to build new 'vocabulary' for fluent expression. 66 | 67 | An exciting extension mechanism is that of inheriting from existing, more primitive geoms after performing some calculation. 68 | 69 | To get feet wet in this world and provide a taste of patterns for geom extension, 'easy geom recipes' provide three basic examples of the geoms_ that inherit from *existing* geoms (point, text, segment, etc) along with a practice exercise: https://evamaerey.github.io/easy-geom-recipes/easy_geom_recipes_compute_group.html. With such geoms, calculation is done under the hood by the ggplot2 system. We'll see how to define a ggproto object; The tutorial keeps things simple by only defining computation at the compute_group stage. 70 | 71 | With new geoms, you can write *new* graphical poems with exciting new graphical 'words'! 72 | 73 | The tutorial is intended for individuals who already have a working knowledge of the grammar of ggplot2, but may like to build a richer vocabulary for themselves. 74 | 75 | ### Motivation 76 | 77 | - ggplot2 extension underutilized 78 | - inaccessibility to demographic that might really benefit 79 | - statistical storytelling benefits from greater fluidity that new geoms afford 80 | 81 | - traditional entry point (themes) might not be compelling to an audience that might be really productive in extension! 82 | 83 | - repetition and numerous examples useful in pedegogical materials 84 | 85 | 86 | 87 | ### Tutorial form 88 | 89 | The introductory tutorial took the form of three examples where a geom_* is successfully created in a step-by-step fashion. After each example, the learner is prompted to create an additional geom that is computationally similar to the example. 90 | 91 | Our recipes take the form: 92 | 93 | - Step 0. Get the job done with ‘base’ ggplot2. It’s a good idea to clarify what needs to happen without getting into the extension architecture 94 | - Step 1. Write a computation function. Wrap the necessary computation into a function that your target geom_*() function will perform. We focus on ‘compute_group’ computation only in this tutorial. 95 | - Step 2. Define a ggproto object. ggproto objects allow your extension to work together with base ggplot2 functions! You’ll use the computation function from step 1 to help define it. 96 | - Step 3. Write your geom function! You’re ready to write your function. You will incorporate the ggproto from step 2 and also define which more primitive geom (point, text, segment etc) you want other behaviors to inherit from. 97 | - Step 4. Test/Enjoy! Take your new geom for a spin! Check out group-wise computation behavior! 98 | 99 | 100 | ## Evaluation 101 | 102 | To test the tutorial, we approached statistics and data analytics educators that we believed would have substantial experience with R and ggplot2 but not necessarily with ggplot2 extension. The study includes nine participants that completed the study and responded to a survey about the tutorial. Eight of the participants were also able to participate in focus group discussions following their completion of the the tutorial and survey. 103 | 104 | 105 | ### Participant profiles 106 | 107 | Recruited participants generally had substantial experience teaching data analytics. 108 | 109 | ![](survey_results_summary_files/figure-html/unnamed-chunk-7-1.png) 110 | 111 | 112 | Study participants all had substantial experience. All had at least five years programming in the R statistical language, with the majority having more than ten years of experience. 113 | 114 | 115 | 116 | 117 | ![hello](survey_results_summary_files/figure-html/q05r_length_user-1.png){width=50%} 118 | 119 | Most of the participants identified as frequent users of R, using the language almost every day. 120 | 121 | ![hello](survey_results_summary_files/figure-html/q06r_frequency-1.png) 122 | 123 | Furthermore, most of the participants (7 of 9) responded that they use ggplot2 several times a week or more. 124 | 125 | ![hello](survey_results_summary_files/figure-html/q07ggplot2_frequency-1.png) 126 | 127 | With respect to writing functions, most of the group had experience writing functions, though the frequency was not as great as with using R and ggplot2. 128 | 129 | 130 | 131 | ![](survey_results_summary_files/figure-html/q08r_frequency-1.png) 132 | 133 | The respondents use ggplot2 in a variety of contexts; notably they all use it in academic research and eight of nine used it in teaching. 134 | 135 | ![](survey_results_summary_files/figure-html/q09ggplot2_contexts-1.png) 136 | 137 | However, participants in general had little or no with writing ggplot2 extensions. Seven of the nine participants were aware of extension packages but had not attempted to write their own extension. One participant had written ggplot2 extensions prior to the tutorial. 138 | 139 | ![](survey_results_summary_files/figure-html/q10_previous_ext_experience-1.png) 140 | 141 | The following figure shows attempts and successes or failures in the different ggplot2 extension areas. 142 | 143 | ![](survey_results_summary_files/figure-html/q11_previous_ext_attempt-1.png) 144 | 145 | The participants had a variety of experiences with object oriented programming, but the majority had no experience with object oriented programming in R. (confirm this w/ underlying data because looking at the summary figure, we can't 100% confirm this. But I think it's true.) 146 | 147 | ![](survey_results_summary_files/figure-html/q13_oop_experience-1.png) 148 | 149 | # Tutorial assessment 150 | 151 | Most participants indicated the tutorial taking them a short amount of time. Six of nine said that on average, each of the recipes took less than 15 minutes to complete. The remaining three participants responded that the recipes took between 15 and 30 minutes on average. 152 | 153 | 154 | ![](survey_results_summary_files/figure-html/q16tutorial_time_taken-1.png) 155 | 156 | The first prompt `geom_point_xy_means` exercise was completed by all participants; and all but one completed the `geom_text_coordinates()` exercise. Several participants failed complete the last recipe (residuals). 157 | 158 | ![](survey_results_summary_files/figure-html/q14which_completed-1.png) 159 | 160 | 161 | ![](survey_results_summary_files/figure-html/q17tutorial_length-1.png) 162 | 163 | 164 | ![](survey_results_summary_files/figure-html/q18example_clarity-1.png) 165 | 166 | 167 | ![](survey_results_summary_files/figure-html/q19examples_engaging-1.png) 168 | 169 | 170 | ![](survey_results_summary_files/figure-html/unnamed-chunk-4-1.png) 171 | 172 | ![](survey_results_summary_files/figure-html/unnamed-chunk-5-1.png) 173 | 174 | 175 | 176 | 177 | --- 178 | 179 | 180 | ![](survey_results_summary_files/figure-html/unnamed-chunk-6-1.png) 181 | 182 | 183 | 184 | ### Focus group highlights... 185 | 186 | #### mechanics 187 | 188 | > '... going through this was super helpful 189 | cuz now I like understand the 190 | mechanics of it all. 191 | 192 | > 'And so I 193 | don't have any intentions of 194 | like making formal geoms on my 195 | own for anything yet. But it was 196 | like really helpful for 197 | understanding how the whole 198 | system works.' 199 | 200 | #### failure w/ previous attempts 201 | 202 | > 'So like there's some other layer of 203 | getting into the ggplot 204 | extension world that I [was] 205 | missing.' 206 | 207 | 208 | #### Step by step and step 0 209 | 210 | > 'So pedagogically, I liked how it 211 | was. I like the the general like 212 | steps like start with like make 213 | the GEOM manually with regular 214 | ggplot and Step 0 just to 215 | have like a baseline and then 216 | going to each of the steps to 217 | get there and then being able to 218 | compare with the original like 219 | as far as like pedagogically, 220 | that was super helpful. Just as 221 | as an approach to to get it 222 | right just so you can have a 223 | goal and see how all of these, 224 | these different primitives and 225 | proto elements and whatever fit together... in such done 226 | that really helpful. 227 | 228 | # Skepticism 229 | 230 | > And it was that easy. And I felt 231 | empowered as a result of that.... But you know, like, my problem 232 | isn't gonna be that easy. 233 | 234 | 235 | # Concern - missing values 236 | 237 | > When, like place where you might 238 | have an opportunity to do a 239 | little bit of pedagogical 240 | caution, it's with like missing 241 | values. So when computing a mean 242 | like ggplot says, by the way, there were three rows I didn't 243 | plot.... So then you 244 | need to explicitly override the 245 | default and that something like 246 | that so that people are not just 247 | blindly putting summaries down 248 | without considering the data 249 | that are being used to make 250 | them. 251 | 252 | 253 | # Accessibility for students 254 | 255 | 256 | > I'm teaching, so I'm 257 | teaching data visualization this 258 | summer online again with my 259 | regular like online classes I've 260 | assigning [the tutorial]... as kind of like an extra 261 | credit thing at the end of 262 | semester to saying like, if 263 | you're interested, go through 264 | this thing and you get 10 bonus 265 | points or something just for the 266 | more advanced students that will 267 | be in the class that will be 268 | interested. 269 | *But I think it's totally accessible for them.* 270 | 271 | # Higher level objectives 272 | 273 | > I'll just add that I I think we, I could definitely use materials 274 | like this and it did raise for 275 | me the points since I didn't 276 | have a lot of experience writing 277 | extensions before like it was 278 | ended up being very 279 | comprehensible to me and so it 280 | kind of made me think that in 281 | like a data visuals, data 282 | visualization classes that we 283 | teach, we probably need to. 284 | There's a balance between 285 | teaching the students to use the 286 | tools that exist right now to 287 | like, just do your analysis and 288 | just do the best with the tools 289 | that are available. But we 290 | probably should include a couple 291 | of weeks on like. 292 | You know, programming and 293 | writing extensions along these 294 | lines, because it's obviously 295 | like very powerful and they need 296 | to have at least some exposure 297 | to it. So you know, a week or 298 | two weeks of materials kind of 299 | like this would be helpful and 300 | would help distinguish them from 301 | like being able to just work 302 | through tutorials on your own 303 | online. Like, if they could 304 | write their own extension, 305 | that's like real value added, 306 | you know, to their organization. 307 | So it it made me think that I 308 | need to think about my data 309 | visualization class A little bit 310 | more as a programming class in 311 | some ways. And I thought that 312 | would be a good. 313 | You know, this is pretty good 314 | material along those lines. 315 | 316 | # Relationship to writing functions 317 | 318 | > Trying to clarify a little bit 319 | more when it's 320 | Useful to have 321 | Your own costume Geom, as 322 | opposed to your own function 323 | 324 | 325 | ### Appendix, example exercise 326 | 327 | For clarity, I include one of the three exercises in the 'easy geom recipes' extension tutorial. First an 'example recipe' `geom_label_id()` is provided, with the step 0-4 guideposts. Then, the student is prompted to create `geom_text_coordinates()`. 328 | 329 | ````{verbatim} 330 | 331 | # Example recipe #2: `geom_label_id()` 332 | 333 | --- 334 | 335 | ## Step 0: use base ggplot2 to get the job done 336 | 337 | 338 | ```{r cars} 339 | # step 0.a 340 | cars %>% 341 | mutate(id_number = 1:n()) %>% 342 | ggplot() + 343 | aes(x = speed, y = dist) + 344 | geom_point() + 345 | geom_label(aes(label = id_number), 346 | hjust = 1.2) 347 | 348 | # step 0.b 349 | layer_data(last_plot(), i = 2) %>% 350 | head() 351 | ``` 352 | 353 | --- 354 | 355 | ## Step 1: computation 356 | 357 | 358 | ```{r compute_group_row_number} 359 | # you won't use the scales argument, but ggplot will later 360 | compute_group_row_number <- function(data, scales){ 361 | 362 | data %>% 363 | # add an additional column called label 364 | # the geom we inherit from requires the label aesthetic 365 | mutate(label = 1:n()) 366 | 367 | } 368 | 369 | # step 1b test the computation function 370 | cars %>% 371 | # input must have required aesthetic inputs as columns 372 | rename(x = speed, y = dist) %>% 373 | compute_group_row_number() %>% 374 | head() 375 | ``` 376 | 377 | --- 378 | 379 | ## Step 2: define ggproto 380 | 381 | 382 | 383 | ```{r StatRownumber} 384 | StatRownumber <- ggplot2::ggproto(`_class` = "StatRownumber", 385 | `_inherit` = ggplot2::Stat, 386 | required_aes = c("x", "y"), 387 | compute_group = compute_group_row_number) 388 | ``` 389 | 390 | 391 | --- 392 | 393 | ## Step 3: define geom_* function 394 | 395 | 396 | 397 | - define the stat and geom for your layer 398 | 399 | 400 | ```{r geom_label_row_number} 401 | geom_label_row_number <- function(mapping = NULL, data = NULL, 402 | position = "identity", na.rm = FALSE, 403 | show.legend = NA, 404 | inherit.aes = TRUE, ...) { 405 | ggplot2::layer( 406 | stat = StatRownumber, # proto object from Step 2 407 | geom = ggplot2::GeomLabel, # inherit other behavior, this time Label 408 | data = data, 409 | mapping = mapping, 410 | position = position, 411 | show.legend = show.legend, 412 | inherit.aes = inherit.aes, 413 | params = list(na.rm = na.rm, ...) 414 | ) 415 | } 416 | ``` 417 | 418 | 419 | 420 | 421 | 422 | --- 423 | 424 | ## Step 4: Enjoy! Use your function 425 | 426 | ```{r enjoy_again} 427 | cars %>% 428 | ggplot() + 429 | aes(x = speed, y = dist) + 430 | geom_point() + 431 | geom_label_row_number(hjust = 1.2) # function in action 432 | ``` 433 | 434 | ### And check out conditionality! 435 | 436 | ```{r conditional_compute} 437 | last_plot() + 438 | aes(color = dist > 60) # Computation is within group 439 | ``` 440 | 441 | 442 | 443 | 444 | --- 445 | 446 | # Task #2: create `geom_text_coordinates()` 447 | 448 | Using recipe #2 as a reference, can you create the function `geom_text_coordinates()`. 449 | 450 | -- 451 | 452 | - geom should label point with its coordinates '(x, y)' 453 | - geom should have behavior of geom_text (not geom_label) 454 | 455 | 456 | Hint: 457 | 458 | ```{r} 459 | paste0("(", 1, ", ",3., ")") 460 | ``` 461 | 462 | 463 | 464 | 465 | ```{r} 466 | # step 0: use base ggplot2 467 | 468 | # step 1: write your compute_group function (and test) 469 | 470 | # step 2: write ggproto with compute_group as an input 471 | 472 | # step 3: write your geom_*() function with ggproto as an input 473 | 474 | # step 4: enjoy! 475 | 476 | 477 | ``` 478 | 479 | 480 | ```` 481 | 482 | 483 | Thanks to Claus Wilke, June Choe, Teun Van der Brand, Isabella Velasquez, Cosima Meyer, and Eric Reder for pre-testing and reviewing the tutorial and providing useful feedback. 484 | -------------------------------------------------------------------------------- /_extensions/coatless/webr/_extension.yml: -------------------------------------------------------------------------------- 1 | name: webr 2 | title: Embedded webr code cells 3 | author: James Joseph Balamuta 4 | version: 0.4.2 5 | quarto-required: ">=1.4.554" 6 | contributes: 7 | filters: 8 | - webr.lua 9 | -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-cell-elements.js: -------------------------------------------------------------------------------- 1 | // Supported Evaluation Types for Context 2 | globalThis.EvalTypes = Object.freeze({ 3 | Interactive: 'interactive', 4 | Setup: 'setup', 5 | Output: 'output', 6 | }); 7 | 8 | // Function that obtains the font size for a given element 9 | globalThis.qwebrCurrentFontSizeOnElement = function(element, cssProperty = 'font-size') { 10 | 11 | const currentFontSize = parseFloat( 12 | window 13 | .getComputedStyle(element) 14 | .getPropertyValue(cssProperty) 15 | ); 16 | 17 | return currentFontSize; 18 | } 19 | 20 | // Function to determine font scaling 21 | globalThis.qwebrScaledFontSize = function(div, qwebrOptions) { 22 | // Determine if we should compute font-size using RevealJS's `--r-main-font-size` 23 | // or if we can directly use the document's `font-size`. 24 | const cssProperty = document.body.classList.contains('reveal') ? 25 | "--r-main-font-size" : "font-size"; 26 | 27 | // Get the current font size on the div element 28 | const elementFontSize = qwebrCurrentFontSizeOnElement(div, cssProperty); 29 | 30 | // Determine the scaled font size value 31 | const scaledFontSize = ((qwebrOptions['editor-font-scale'] ?? 1) * elementFontSize) ?? 17.5; 32 | 33 | return scaledFontSize; 34 | } 35 | 36 | 37 | // Function that dispatches the creation request 38 | globalThis.qwebrCreateHTMLElement = function ( 39 | cellData 40 | ) { 41 | 42 | // Extract key components 43 | const evalType = cellData.options.context; 44 | const qwebrCounter = cellData.id; 45 | 46 | // We make an assumption that insertion points are defined by the Lua filter as: 47 | // qwebr-insertion-location-{qwebrCounter} 48 | const elementLocator = document.getElementById(`qwebr-insertion-location-${qwebrCounter}`); 49 | 50 | // Figure out the routine to use to insert the element. 51 | let qwebrElement; 52 | switch ( evalType ) { 53 | case EvalTypes.Interactive: 54 | qwebrElement = qwebrCreateInteractiveElement(qwebrCounter, cellData.options); 55 | break; 56 | case EvalTypes.Output: 57 | qwebrElement = qwebrCreateNonInteractiveOutputElement(qwebrCounter, cellData.options); 58 | break; 59 | case EvalTypes.Setup: 60 | qwebrElement = qwebrCreateNonInteractiveSetupElement(qwebrCounter, cellData.options); 61 | break; 62 | default: 63 | qwebrElement = document.createElement('div'); 64 | qwebrElement.textContent = 'Error creating `quarto-webr` element'; 65 | } 66 | 67 | // Insert the dynamically generated object at the document location. 68 | elementLocator.appendChild(qwebrElement); 69 | }; 70 | 71 | // Function that setups the interactive element creation 72 | globalThis.qwebrCreateInteractiveElement = function (qwebrCounter, qwebrOptions) { 73 | 74 | // Create main div element 75 | var mainDiv = document.createElement('div'); 76 | mainDiv.id = 'qwebr-interactive-area-' + qwebrCounter; 77 | mainDiv.className = `qwebr-interactive-area`; 78 | if (qwebrOptions.classes) { 79 | mainDiv.className += " " + qwebrOptions.classes 80 | } 81 | 82 | // Add a unique cell identifier that users can customize 83 | if (qwebrOptions.label) { 84 | mainDiv.setAttribute('data-id', qwebrOptions.label); 85 | } 86 | 87 | // Create toolbar div 88 | var toolbarDiv = document.createElement('div'); 89 | toolbarDiv.className = 'qwebr-editor-toolbar'; 90 | toolbarDiv.id = 'qwebr-editor-toolbar-' + qwebrCounter; 91 | 92 | // Create a div to hold the left buttons 93 | var leftButtonsDiv = document.createElement('div'); 94 | leftButtonsDiv.className = 'qwebr-editor-toolbar-left-buttons'; 95 | 96 | // Create a div to hold the right buttons 97 | var rightButtonsDiv = document.createElement('div'); 98 | rightButtonsDiv.className = 'qwebr-editor-toolbar-right-buttons'; 99 | 100 | // Create Run Code button 101 | var runCodeButton = document.createElement('button'); 102 | runCodeButton.className = 'btn btn-default qwebr-button qwebr-button-run'; 103 | runCodeButton.disabled = true; 104 | runCodeButton.type = 'button'; 105 | runCodeButton.id = 'qwebr-button-run-' + qwebrCounter; 106 | runCodeButton.textContent = '🟡 Loading webR...'; 107 | runCodeButton.title = `Run code (Shift + Enter)`; 108 | 109 | // Append buttons to the leftButtonsDiv 110 | leftButtonsDiv.appendChild(runCodeButton); 111 | 112 | // Create Reset button 113 | var resetButton = document.createElement('button'); 114 | resetButton.className = 'btn btn-light btn-xs qwebr-button qwebr-button-reset'; 115 | resetButton.type = 'button'; 116 | resetButton.id = 'qwebr-button-reset-' + qwebrCounter; 117 | resetButton.title = 'Start over'; 118 | resetButton.innerHTML = ''; 119 | 120 | // Create Copy button 121 | var copyButton = document.createElement('button'); 122 | copyButton.className = 'btn btn-light btn-xs qwebr-button qwebr-button-copy'; 123 | copyButton.type = 'button'; 124 | copyButton.id = 'qwebr-button-copy-' + qwebrCounter; 125 | copyButton.title = 'Copy code'; 126 | copyButton.innerHTML = ''; 127 | 128 | // Append buttons to the rightButtonsDiv 129 | rightButtonsDiv.appendChild(resetButton); 130 | rightButtonsDiv.appendChild(copyButton); 131 | 132 | // Create console area div 133 | var consoleAreaDiv = document.createElement('div'); 134 | consoleAreaDiv.id = 'qwebr-console-area-' + qwebrCounter; 135 | consoleAreaDiv.className = 'qwebr-console-area'; 136 | 137 | // Create editor div 138 | var editorDiv = document.createElement('div'); 139 | editorDiv.id = 'qwebr-editor-' + qwebrCounter; 140 | editorDiv.className = 'qwebr-editor'; 141 | 142 | // Create output code area div 143 | var outputCodeAreaDiv = document.createElement('div'); 144 | outputCodeAreaDiv.id = 'qwebr-output-code-area-' + qwebrCounter; 145 | outputCodeAreaDiv.className = 'qwebr-output-code-area'; 146 | outputCodeAreaDiv.setAttribute('aria-live', 'assertive'); 147 | 148 | // Create pre element inside output code area 149 | var preElement = document.createElement('pre'); 150 | preElement.style.visibility = 'hidden'; 151 | outputCodeAreaDiv.appendChild(preElement); 152 | 153 | // Create output graph area div 154 | var outputGraphAreaDiv = document.createElement('div'); 155 | outputGraphAreaDiv.id = 'qwebr-output-graph-area-' + qwebrCounter; 156 | outputGraphAreaDiv.className = 'qwebr-output-graph-area'; 157 | 158 | // Append buttons to the toolbar 159 | toolbarDiv.appendChild(leftButtonsDiv); 160 | toolbarDiv.appendChild(rightButtonsDiv); 161 | 162 | // Append all elements to the main div 163 | mainDiv.appendChild(toolbarDiv); 164 | consoleAreaDiv.appendChild(editorDiv); 165 | consoleAreaDiv.appendChild(outputCodeAreaDiv); 166 | mainDiv.appendChild(consoleAreaDiv); 167 | mainDiv.appendChild(outputGraphAreaDiv); 168 | 169 | return mainDiv; 170 | } 171 | 172 | // Function that adds output structure for non-interactive output 173 | globalThis.qwebrCreateNonInteractiveOutputElement = function(qwebrCounter, qwebrOptions) { 174 | // Create main div element 175 | var mainDiv = document.createElement('div'); 176 | mainDiv.id = 'qwebr-noninteractive-area-' + qwebrCounter; 177 | mainDiv.className = `qwebr-noninteractive-area`; 178 | if (qwebrOptions.classes) { 179 | mainDiv.className += " " + qwebrOptions.classes 180 | } 181 | 182 | // Add a unique cell identifier that users can customize 183 | if (qwebrOptions.label) { 184 | mainDiv.setAttribute('data-id', qwebrOptions.label); 185 | } 186 | 187 | // Create a status container div 188 | var statusContainer = createLoadingContainer(qwebrCounter); 189 | 190 | // Create output code area div 191 | var outputCodeAreaDiv = document.createElement('div'); 192 | outputCodeAreaDiv.id = 'qwebr-output-code-area-' + qwebrCounter; 193 | outputCodeAreaDiv.className = 'qwebr-output-code-area'; 194 | outputCodeAreaDiv.setAttribute('aria-live', 'assertive'); 195 | 196 | // Create pre element inside output code area 197 | var preElement = document.createElement('pre'); 198 | preElement.style.visibility = 'hidden'; 199 | outputCodeAreaDiv.appendChild(preElement); 200 | 201 | // Create output graph area div 202 | var outputGraphAreaDiv = document.createElement('div'); 203 | outputGraphAreaDiv.id = 'qwebr-output-graph-area-' + qwebrCounter; 204 | outputGraphAreaDiv.className = 'qwebr-output-graph-area'; 205 | 206 | // Append all elements to the main div 207 | mainDiv.appendChild(statusContainer); 208 | mainDiv.appendChild(outputCodeAreaDiv); 209 | mainDiv.appendChild(outputGraphAreaDiv); 210 | 211 | return mainDiv; 212 | }; 213 | 214 | // Function that adds a stub in the page to indicate a setup cell was used. 215 | globalThis.qwebrCreateNonInteractiveSetupElement = function(qwebrCounter, qwebrOptions) { 216 | // Create main div element 217 | var mainDiv = document.createElement('div'); 218 | mainDiv.id = `qwebr-noninteractive-setup-area-${qwebrCounter}`; 219 | mainDiv.className = `qwebr-noninteractive-setup-area`; 220 | if (qwebrOptions.classes) { 221 | mainDiv.className += " " + qwebrOptions.classes 222 | } 223 | 224 | 225 | // Add a unique cell identifier that users can customize 226 | if (qwebrOptions.label) { 227 | mainDiv.setAttribute('data-id', qwebrOptions.label); 228 | } 229 | 230 | // Create a status container div 231 | var statusContainer = createLoadingContainer(qwebrCounter); 232 | 233 | // Append status onto the main div 234 | mainDiv.appendChild(statusContainer); 235 | 236 | return mainDiv; 237 | } 238 | 239 | 240 | // Function to create loading container with specified ID 241 | globalThis.createLoadingContainer = function(qwebrCounter) { 242 | 243 | // Create a status container 244 | const container = document.createElement('div'); 245 | container.id = `qwebr-non-interactive-loading-container-${qwebrCounter}`; 246 | container.className = 'qwebr-non-interactive-loading-container qwebr-cell-needs-evaluation'; 247 | 248 | // Create an R project logo to indicate its a code space 249 | const rProjectIcon = document.createElement('i'); 250 | rProjectIcon.className = 'fa-brands fa-r-project fa-3x qwebr-r-project-logo'; 251 | 252 | // Setup a loading icon from font awesome 253 | const spinnerIcon = document.createElement('i'); 254 | spinnerIcon.className = 'fa-solid fa-spinner fa-spin fa-1x qwebr-icon-status-spinner'; 255 | 256 | // Add a section for status text 257 | const statusText = document.createElement('p'); 258 | statusText.id = `qwebr-status-text-${qwebrCounter}`; 259 | statusText.className = `qwebr-status-text qwebr-cell-needs-evaluation`; 260 | statusText.innerText = 'Loading webR...'; 261 | 262 | // Incorporate an inner container 263 | const innerContainer = document.createElement('div'); 264 | 265 | // Append elements to the inner container 266 | innerContainer.appendChild(spinnerIcon); 267 | innerContainer.appendChild(statusText); 268 | 269 | // Append elements to the main container 270 | container.appendChild(rProjectIcon); 271 | container.appendChild(innerContainer); 272 | 273 | return container; 274 | } -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-cell-initialization.js: -------------------------------------------------------------------------------- 1 | // Handle cell initialization initialization 2 | qwebrCellDetails.map( 3 | (entry) => { 4 | // Handle the creation of the element 5 | qwebrCreateHTMLElement(entry); 6 | // In the event of interactive, initialize the monaco editor 7 | if (entry.options.context == EvalTypes.Interactive) { 8 | qwebrCreateMonacoEditorInstance(entry); 9 | } 10 | } 11 | ); 12 | 13 | // Identify non-interactive cells (in order) 14 | const filteredEntries = qwebrCellDetails.filter(entry => { 15 | const contextOption = entry.options && entry.options.context; 16 | return ['output', 'setup'].includes(contextOption) || (contextOption == "interactive" && entry.options && entry.options.autorun === 'true'); 17 | }); 18 | 19 | // Condition non-interactive cells to only be run after webR finishes its initialization. 20 | qwebrInstance.then( 21 | async () => { 22 | const nHiddenCells = filteredEntries.length; 23 | var currentHiddenCell = 0; 24 | 25 | 26 | // Modify button state 27 | qwebrSetInteractiveButtonState(`🟡 Running hidden code cells ...`, false); 28 | 29 | // Begin processing non-interactive sections 30 | // Due to the iteration policy, we must use a for() loop. 31 | // Otherwise, we would need to switch to using reduce with an empty 32 | // starting promise 33 | for (const entry of filteredEntries) { 34 | 35 | // Determine cell being examined 36 | currentHiddenCell = currentHiddenCell + 1; 37 | const formattedMessage = `Evaluating hidden cell ${currentHiddenCell} out of ${nHiddenCells}`; 38 | 39 | // Update the document status header 40 | if (qwebrShowStartupMessage) { 41 | qwebrUpdateStatusHeader(formattedMessage); 42 | } 43 | 44 | // Display the update in non-active areas 45 | qwebrUpdateStatusMessage(formattedMessage); 46 | 47 | // Extract details on the active cell 48 | const evalType = entry.options.context; 49 | const cellCode = entry.code; 50 | const qwebrCounter = entry.id; 51 | 52 | if (['output', 'setup'].includes(evalType)) { 53 | // Disable further global status updates 54 | const activeContainer = document.getElementById(`qwebr-non-interactive-loading-container-${qwebrCounter}`); 55 | activeContainer.classList.remove('qwebr-cell-needs-evaluation'); 56 | activeContainer.classList.add('qwebr-cell-evaluated'); 57 | 58 | // Update status on the code cell 59 | const activeStatus = document.getElementById(`qwebr-status-text-${qwebrCounter}`); 60 | activeStatus.innerText = " Evaluating hidden code cell..."; 61 | activeStatus.classList.remove('qwebr-cell-needs-evaluation'); 62 | activeStatus.classList.add('qwebr-cell-evaluated'); 63 | } 64 | 65 | switch (evalType) { 66 | case 'interactive': 67 | // TODO: Make this more standardized. 68 | // At the moment, we're overriding the interactive status update by pretending its 69 | // output-like. 70 | const tempOptions = entry.options; 71 | tempOptions["context"] = "output" 72 | // Run the code in a non-interactive state that is geared to displaying output 73 | await qwebrExecuteCode(`${cellCode}`, qwebrCounter, tempOptions); 74 | break; 75 | case 'output': 76 | // Run the code in a non-interactive state that is geared to displaying output 77 | await qwebrExecuteCode(`${cellCode}`, qwebrCounter, entry.options); 78 | break; 79 | case 'setup': 80 | const activeDiv = document.getElementById(`qwebr-noninteractive-setup-area-${qwebrCounter}`); 81 | 82 | // Store code in history 83 | qwebrLogCodeToHistory(cellCode, entry.options); 84 | 85 | // Run the code in a non-interactive state with all output thrown away 86 | await mainWebR.evalRVoid(`${cellCode}`); 87 | break; 88 | default: 89 | break; 90 | } 91 | 92 | if (['output', 'setup'].includes(evalType)) { 93 | // Disable further global status updates 94 | const activeContainer = document.getElementById(`qwebr-non-interactive-loading-container-${qwebrCounter}`); 95 | // Disable visibility 96 | activeContainer.style.visibility = 'hidden'; 97 | activeContainer.style.display = 'none'; 98 | } 99 | } 100 | } 101 | ).then( 102 | () => { 103 | // Release document status as ready 104 | 105 | if (qwebrShowStartupMessage) { 106 | qwebrStartupMessage.innerText = "🟢 Ready!" 107 | } 108 | 109 | qwebrSetInteractiveButtonState( 110 | ` Run Code`, 111 | true 112 | ); 113 | } 114 | ); -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-compute-engine.js: -------------------------------------------------------------------------------- 1 | // Function to verify a given JavaScript Object is empty 2 | globalThis.qwebrIsObjectEmpty = function (arr) { 3 | return Object.keys(arr).length === 0; 4 | } 5 | 6 | // Global version of the Escape HTML function that converts HTML 7 | // characters to their HTML entities. 8 | globalThis.qwebrEscapeHTMLCharacters = function(unsafe) { 9 | return unsafe 10 | .replace(/&/g, "&") 11 | .replace(//g, ">") 13 | .replace(/"/g, """) 14 | .replace(/'/g, "'"); 15 | }; 16 | 17 | // Passthrough results 18 | globalThis.qwebrIdentity = function(x) { 19 | return x; 20 | }; 21 | 22 | // Append a comment 23 | globalThis.qwebrPrefixComment = function(x, comment) { 24 | return `${comment}${x}`; 25 | }; 26 | 27 | // Function to store the code in the history 28 | globalThis.qwebrLogCodeToHistory = function(codeToRun, options) { 29 | qwebrRCommandHistory.push( 30 | `# Ran code in ${options.label} at ${new Date().toLocaleString()} ----\n${codeToRun}` 31 | ); 32 | } 33 | 34 | // Function to attach a download button onto the canvas 35 | // allowing the user to download the image. 36 | function qwebrImageCanvasDownloadButton(canvas, canvasContainer) { 37 | 38 | // Create the download button 39 | const downloadButton = document.createElement('button'); 40 | downloadButton.className = 'qwebr-canvas-image-download-btn'; 41 | downloadButton.textContent = 'Download Image'; 42 | canvasContainer.appendChild(downloadButton); 43 | 44 | // Trigger a download of the image when the button is clicked 45 | downloadButton.addEventListener('click', function() { 46 | const image = canvas.toDataURL('image/png'); 47 | const link = document.createElement('a'); 48 | link.href = image; 49 | link.download = 'qwebr-canvas-image.png'; 50 | link.click(); 51 | }); 52 | } 53 | 54 | 55 | // Function to parse the pager results 56 | globalThis.qwebrParseTypePager = async function (msg) { 57 | 58 | // Split out the event data 59 | const { path, title, deleteFile } = msg.data; 60 | 61 | // Process the pager data by reading the information from disk 62 | const paged_data = await mainWebR.FS.readFile(path).then((data) => { 63 | // Obtain the file content 64 | let content = new TextDecoder().decode(data); 65 | 66 | // Remove excessive backspace characters until none remain 67 | while(content.match(/.[\b]/)){ 68 | content = content.replace(/.[\b]/g, ''); 69 | } 70 | 71 | // Returned cleaned data 72 | return content; 73 | }); 74 | 75 | // Unlink file if needed 76 | if (deleteFile) { 77 | await mainWebR.FS.unlink(path); 78 | } 79 | 80 | // Return extracted data with spaces 81 | return paged_data; 82 | } 83 | 84 | // Function to run the code using webR and parse the output 85 | globalThis.qwebrComputeEngine = async function( 86 | codeToRun, 87 | elements, 88 | options) { 89 | 90 | // Call into the R compute engine that persists within the document scope. 91 | // To be prepared for all scenarios, the following happens: 92 | // 1. We setup a canvas device to write to by making a namespace call into the {webr} package 93 | // 2. We use values inside of the options array to set the figure size. 94 | // 3. We capture the output stream information (STDOUT and STERR) 95 | // 4. We disable the current device's image creation. 96 | // 5. Piece-wise parse the results into the different output areas 97 | 98 | // Create a pager variable for help/file contents 99 | let pager = []; 100 | 101 | // Handle how output is processed 102 | let showMarkup = options.results === "markup" && options.output !== "asis"; 103 | let processOutput; 104 | 105 | if (showMarkup) { 106 | processOutput = qwebrEscapeHTMLCharacters; 107 | } else { 108 | processOutput = qwebrIdentity; 109 | } 110 | 111 | // ---- 112 | // Convert from Inches to Pixels by using DPI (dots per inch) 113 | // for bitmap devices (dpi * inches = pixels) 114 | let fig_width = options["fig-width"] * options["dpi"] 115 | let fig_height = options["fig-height"] * options["dpi"] 116 | 117 | // Initialize webR 118 | await mainWebR.init(); 119 | 120 | // Configure capture output 121 | let captureOutputOptions = { 122 | withAutoprint: true, 123 | captureStreams: true, 124 | captureConditions: false, 125 | // env: webR.objs.emptyEnv, // maintain a global environment for webR v0.2.0 126 | }; 127 | 128 | // Determine if the browser supports OffScreen 129 | if (qwebrOffScreenCanvasSupport()) { 130 | // Mirror default options of webr::canvas() 131 | // with changes to figure height and width. 132 | captureOutputOptions.captureGraphics = { 133 | width: fig_width, 134 | height: fig_height, 135 | bg: "white", // default: transparent 136 | pointsize: 12, 137 | capture: true 138 | }; 139 | } else { 140 | // Disable generating graphics 141 | captureOutputOptions.captureGraphics = false; 142 | } 143 | 144 | // Store the code to run in history 145 | qwebrLogCodeToHistory(codeToRun, options); 146 | 147 | // Setup a webR canvas by making a namespace call into the {webr} package 148 | // Evaluate the R code 149 | // Remove the active canvas silently 150 | const result = await mainWebRCodeShelter.captureR( 151 | `${codeToRun}`, 152 | captureOutputOptions 153 | ); 154 | 155 | // ----- 156 | 157 | // Start attempting to parse the result data 158 | processResultOutput:try { 159 | 160 | // Avoid running through output processing 161 | if (options.results === "hide" || options.output === "false") { 162 | break processResultOutput; 163 | } 164 | 165 | // Merge output streams of STDOUT and STDErr (messages and errors are combined.) 166 | // Require both `warning` and `message` to be true to display `STDErr`. 167 | const out = result.output 168 | .filter( 169 | evt => evt.type === "stdout" || 170 | ( evt.type === "stderr" && (options.warning === "true" && options.message === "true")) 171 | ) 172 | .map((evt, index) => { 173 | const className = `qwebr-output-code-${evt.type}`; 174 | const outputResult = qwebrPrefixComment(processOutput(evt.data), options.comment); 175 | return `${outputResult}`; 176 | }) 177 | .join("\n"); 178 | 179 | 180 | // Clean the state 181 | // We're now able to process pager events. 182 | // As a result, we cannot maintain a true 1-to-1 output order 183 | // without individually feeding each line 184 | const msgs = await mainWebR.flush(); 185 | 186 | // Use `map` to process the filtered "pager" events asynchronously 187 | const pager = await Promise.all( 188 | msgs.filter(msg => msg.type === 'pager').map( 189 | async (msg) => { 190 | return await qwebrParseTypePager(msg); 191 | } 192 | ) 193 | ); 194 | 195 | // Nullify the output area of content 196 | elements.outputCodeDiv.innerHTML = ""; 197 | elements.outputGraphDiv.innerHTML = ""; 198 | 199 | // Design an output object for messages 200 | const pre = document.createElement("pre"); 201 | if (/\S/.test(out)) { 202 | // Display results as HTML elements to retain output styling 203 | const div = document.createElement("div"); 204 | div.innerHTML = out; 205 | 206 | // Calculate a scaled font-size value 207 | const scaledFontSize = qwebrScaledFontSize( 208 | elements.outputCodeDiv, options); 209 | 210 | // Override output code cell size 211 | pre.style.fontSize = `${scaledFontSize}px`; 212 | pre.appendChild(div); 213 | } else { 214 | // If nothing is present, hide the element. 215 | pre.style.visibility = "hidden"; 216 | } 217 | 218 | elements.outputCodeDiv.appendChild(pre); 219 | 220 | // Determine if we have graphs to display 221 | if (result.images.length > 0) { 222 | 223 | // Create figure element 224 | const figureElement = document.createElement("figure"); 225 | figureElement.className = "qwebr-canvas-image"; 226 | 227 | // Place each rendered graphic onto a canvas element 228 | result.images.forEach((img) => { 229 | 230 | // Construct canvas for object 231 | const canvas = document.createElement("canvas"); 232 | 233 | // Add an image download button 234 | qwebrImageCanvasDownloadButton(canvas, figureElement); 235 | 236 | // Set canvas size to image 237 | canvas.width = img.width; 238 | canvas.height = img.height; 239 | 240 | // Apply output truncations 241 | canvas.style.width = options["out-width"] ? options["out-width"] : `${fig_width}px`; 242 | if (options["out-height"]) { 243 | canvas.style.height = options["out-height"]; 244 | } 245 | 246 | // Apply styling 247 | canvas.style.display = "block"; 248 | canvas.style.margin = "auto"; 249 | 250 | // Draw image onto Canvas 251 | const ctx = canvas.getContext("2d"); 252 | ctx.drawImage(img, 0, 0, img.width, img.height); 253 | 254 | // Append canvas to figure output area 255 | figureElement.appendChild(canvas); 256 | 257 | }); 258 | 259 | if (options['fig-cap']) { 260 | // Create figcaption element 261 | const figcaptionElement = document.createElement('figcaption'); 262 | figcaptionElement.innerText = options['fig-cap']; 263 | // Append figcaption to figure 264 | figureElement.appendChild(figcaptionElement); 265 | } 266 | 267 | elements.outputGraphDiv.appendChild(figureElement); 268 | 269 | } 270 | 271 | // Display the pager data 272 | if (pager) { 273 | // Use the `pre` element to preserve whitespace. 274 | pager.forEach((paged_data, index) => { 275 | let pre_pager = document.createElement("pre"); 276 | pre_pager.innerText = paged_data; 277 | pre_pager.classList.add("qwebr-output-code-pager"); 278 | pre_pager.setAttribute("id", `qwebr-output-code-pager-editor-${elements.id}-result-${index + 1}`); 279 | elements.outputCodeDiv.appendChild(pre_pager); 280 | }); 281 | } 282 | } finally { 283 | // Clean up the remaining code 284 | mainWebRCodeShelter.purge(); 285 | } 286 | } 287 | 288 | // Function to execute the code (accepts code as an argument) 289 | globalThis.qwebrExecuteCode = async function ( 290 | codeToRun, 291 | id, 292 | options = {}) { 293 | 294 | // If options are not passed, we fall back on the bare minimum to handle the computation 295 | if (qwebrIsObjectEmpty(options)) { 296 | options = { 297 | "context": "interactive", 298 | "fig-width": 7, "fig-height": 5, 299 | "out-width": "700px", "out-height": "", 300 | "dpi": 72, 301 | "results": "markup", 302 | "warning": "true", "message": "true", 303 | }; 304 | } 305 | 306 | // Next, we access the compute areas values 307 | const elements = { 308 | runButton: document.getElementById(`qwebr-button-run-${id}`), 309 | outputCodeDiv: document.getElementById(`qwebr-output-code-area-${id}`), 310 | outputGraphDiv: document.getElementById(`qwebr-output-graph-area-${id}`), 311 | id: id, 312 | } 313 | 314 | // Disallowing execution of other code cells 315 | document.querySelectorAll(".qwebr-button-run").forEach((btn) => { 316 | btn.disabled = true; 317 | }); 318 | 319 | if (options.context == EvalTypes.Interactive) { 320 | // Emphasize the active code cell 321 | elements.runButton.innerHTML = ' Run Code'; 322 | } 323 | 324 | // Evaluate the code and parse the output into the document 325 | await qwebrComputeEngine(codeToRun, elements, options); 326 | 327 | // Switch to allowing execution of code 328 | document.querySelectorAll(".qwebr-button-run").forEach((btn) => { 329 | btn.disabled = false; 330 | }); 331 | 332 | if (options.context == EvalTypes.Interactive) { 333 | // Revert to the initial code cell state 334 | elements.runButton.innerHTML = ' Run Code'; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-document-engine-initialization.js: -------------------------------------------------------------------------------- 1 | // Function to install a single package 2 | async function qwebrInstallRPackage(packageName) { 3 | await mainWebR.evalRVoid(`webr::install('${packageName}');`); 4 | } 5 | 6 | // Function to load a single package 7 | async function qwebrLoadRPackage(packageName) { 8 | await mainWebR.evalRVoid(`require('${packageName}', quietly = TRUE)`); 9 | } 10 | 11 | // Generic function to process R packages 12 | async function qwebrProcessRPackagesWithStatus(packages, processType, displayStatusMessageUpdate = true) { 13 | // Switch between contexts 14 | const messagePrefix = processType === 'install' ? 'Installing' : 'Loading'; 15 | 16 | // Modify button state 17 | qwebrSetInteractiveButtonState(`🟡 ${messagePrefix} package ...`, false); 18 | 19 | // Iterate over packages 20 | for (let i = 0; i < packages.length; i++) { 21 | const activePackage = packages[i]; 22 | const formattedMessage = `${messagePrefix} package ${i + 1} out of ${packages.length}: ${activePackage}`; 23 | 24 | // Display the update in header 25 | if (displayStatusMessageUpdate) { 26 | qwebrUpdateStatusHeader(formattedMessage); 27 | } 28 | 29 | // Display the update in non-active areas 30 | qwebrUpdateStatusMessage(formattedMessage); 31 | 32 | // Run package installation 33 | if (processType === 'install') { 34 | await qwebrInstallRPackage(activePackage); 35 | } else { 36 | await qwebrLoadRPackage(activePackage); 37 | } 38 | } 39 | 40 | // Clean slate 41 | if (processType === 'load') { 42 | await mainWebR.flush(); 43 | } 44 | } 45 | 46 | // Start a timer 47 | const initializeWebRTimerStart = performance.now(); 48 | 49 | // Encase with a dynamic import statement 50 | globalThis.qwebrInstance = import(qwebrCustomizedWebROptions.baseURL + "webr.mjs").then( 51 | async ({ WebR, ChannelType }) => { 52 | // Populate WebR options with defaults or new values based on `webr` meta 53 | globalThis.mainWebR = new WebR(qwebrCustomizedWebROptions); 54 | 55 | // Initialization WebR 56 | await mainWebR.init(); 57 | 58 | // Setup a shelter 59 | globalThis.mainWebRCodeShelter = await new mainWebR.Shelter(); 60 | 61 | // Setup a pager to allow processing help documentation 62 | await mainWebR.evalRVoid('webr::pager_install()'); 63 | 64 | // Override the existing install.packages() to use webr::install() 65 | await mainWebR.evalRVoid('webr::shim_install()'); 66 | 67 | // Specify the repositories to pull from 68 | // Note: webR does not use the `repos` option, but instead uses `webr_pkg_repos` 69 | // inside of `install()`. However, other R functions still pull from `repos`. 70 | await mainWebR.evalRVoid(` 71 | options( 72 | webr_pkg_repos = c(${qwebrPackageRepoURLS.map(repoURL => `'${repoURL}'`).join(',')}), 73 | repos = c(${qwebrPackageRepoURLS.map(repoURL => `'${repoURL}'`).join(',')}) 74 | ) 75 | `); 76 | 77 | // Check to see if any packages need to be installed 78 | if (qwebrSetupRPackages) { 79 | // Obtain only a unique list of packages 80 | const uniqueRPackageList = Array.from(new Set(qwebrInstallRPackagesList)); 81 | 82 | // Install R packages one at a time (either silently or with a status update) 83 | await qwebrProcessRPackagesWithStatus(uniqueRPackageList, 'install', qwebrShowStartupMessage); 84 | 85 | if (qwebrAutoloadRPackages) { 86 | // Load R packages one at a time (either silently or with a status update) 87 | await qwebrProcessRPackagesWithStatus(uniqueRPackageList, 'load', qwebrShowStartupMessage); 88 | } 89 | } 90 | } 91 | ); 92 | 93 | // Stop timer 94 | const initializeWebRTimerEnd = performance.now(); 95 | -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-document-history.js: -------------------------------------------------------------------------------- 1 | // Define a global storage and retrieval solution ---- 2 | 3 | // Store commands executed in R 4 | globalThis.qwebrRCommandHistory = []; 5 | 6 | // Function to retrieve the command history 7 | globalThis.qwebrFormatRHistory = function() { 8 | return qwebrRCommandHistory.join("\n\n"); 9 | } 10 | 11 | // Retrieve HTML Elements ---- 12 | 13 | // Get the command modal 14 | const command_history_modal = document.getElementById("qwebr-history-modal"); 15 | 16 | // Get the button that opens the command modal 17 | const command_history_btn = document.getElementById("qwebrRHistoryButton"); 18 | 19 | // Get the element that closes the command modal 20 | const command_history_close_span = document.getElementById("qwebr-command-history-close-btn"); 21 | 22 | // Get the download button for r history information 23 | const command_history_download_btn = document.getElementById("qwebr-download-history-btn"); 24 | 25 | // Plug in command history into modal/download button ---- 26 | 27 | // Function to populate the modal with command history 28 | function populateCommandHistoryModal() { 29 | document.getElementById("qwebr-command-history-contents").innerHTML = qwebrFormatRHistory() || "No commands have been executed yet."; 30 | } 31 | 32 | // Function to format the current date and time to 33 | // a string with the format YYYY-MM-DD-HH-MM-SS 34 | function formatDateTime() { 35 | const now = new Date(); 36 | 37 | const year = now.getFullYear(); 38 | const day = String(now.getDate()).padStart(2, '0'); 39 | const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are zero-based 40 | const hours = String(now.getHours()).padStart(2, '0'); 41 | const minutes = String(now.getMinutes()).padStart(2, '0'); 42 | const seconds = String(now.getSeconds()).padStart(2, '0'); 43 | 44 | return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`; 45 | } 46 | 47 | 48 | // Function to convert document title with datetime to a safe filename 49 | function safeFileName() { 50 | // Get the current page title 51 | let pageTitle = document.title; 52 | 53 | // Combine the current page title with the current date and time 54 | let pageNameWithDateTime = `Rhistory-${pageTitle}-${formatDateTime()}`; 55 | 56 | // Replace unsafe characters with safe alternatives 57 | let safeFilename = pageNameWithDateTime.replace(/[\\/:\*\?! "<>\|]/g, '-'); 58 | 59 | return safeFilename; 60 | } 61 | 62 | 63 | // Function to download list contents as text file 64 | function downloadRHistory() { 65 | // Get the current page title + datetime and use it as the filename 66 | const filename = `${safeFileName()}.R`; 67 | 68 | // Get the text contents of the R History list 69 | const text = qwebrFormatRHistory(); 70 | 71 | // Create a new Blob object with the text contents 72 | const blob = new Blob([text], { type: 'text/plain' }); 73 | 74 | // Create a new anchor element for the download 75 | const a = document.createElement('a'); 76 | a.style.display = 'none'; 77 | a.href = URL.createObjectURL(blob); 78 | a.download = filename; 79 | 80 | // Append the anchor to the body, click it, and remove it 81 | document.body.appendChild(a); 82 | a.click(); 83 | document.body.removeChild(a); 84 | } 85 | 86 | // Register event handlers ---- 87 | 88 | // When the user clicks the View R History button, open the command modal 89 | command_history_btn.onclick = function() { 90 | populateCommandHistoryModal(); 91 | command_history_modal.style.display = "block"; 92 | } 93 | 94 | // When the user clicks on (x), close the command modal 95 | command_history_close_span.onclick = function() { 96 | command_history_modal.style.display = "none"; 97 | } 98 | 99 | // When the user clicks anywhere outside of the command modal, close it 100 | window.onclick = function(event) { 101 | if (event.target == command_history_modal) { 102 | command_history_modal.style.display = "none"; 103 | } 104 | } 105 | 106 | // Add an onclick event listener to the download button so that 107 | // the user can download the R history as a text file 108 | command_history_download_btn.onclick = function() { 109 | downloadRHistory(); 110 | }; -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-document-settings.js: -------------------------------------------------------------------------------- 1 | // Document level settings ---- 2 | 3 | // Determine if we need to install R packages 4 | globalThis.qwebrInstallRPackagesList = [{{INSTALLRPACKAGESLIST}}]; 5 | 6 | // Specify possible locations to search for the repository 7 | globalThis.qwebrPackageRepoURLS = [{{RPACKAGEREPOURLS}}]; 8 | 9 | // Check to see if we have an empty array, if we do set to skip the installation. 10 | globalThis.qwebrSetupRPackages = !(qwebrInstallRPackagesList.indexOf("") !== -1); 11 | globalThis.qwebrAutoloadRPackages = {{AUTOLOADRPACKAGES}}; 12 | 13 | // Display a startup message? 14 | globalThis.qwebrShowStartupMessage = {{SHOWSTARTUPMESSAGE}}; 15 | globalThis.qwebrShowHeaderMessage = {{SHOWHEADERMESSAGE}}; 16 | 17 | // Describe the webR settings that should be used 18 | globalThis.qwebrCustomizedWebROptions = { 19 | "baseURL": "{{BASEURL}}", 20 | "serviceWorkerUrl": "{{SERVICEWORKERURL}}", 21 | "homedir": "{{HOMEDIR}}", 22 | "channelType": "{{CHANNELTYPE}}" 23 | }; 24 | 25 | // Store cell data 26 | globalThis.qwebrCellDetails = {{QWEBRCELLDETAILS}}; 27 | -------------------------------------------------------------------------------- /_extensions/coatless/webr/qwebr-document-status.js: -------------------------------------------------------------------------------- 1 | // Declare startupMessageQWebR globally 2 | globalThis.qwebrStartupMessage = document.createElement("p"); 3 | 4 | // Verify if OffScreenCanvas is supported 5 | globalThis.qwebrOffScreenCanvasSupport = function() { 6 | return typeof OffscreenCanvas !== 'undefined' 7 | } 8 | 9 | // Function to set the button text 10 | globalThis.qwebrSetInteractiveButtonState = function(buttonText, enableCodeButton = true) { 11 | document.querySelectorAll(".qwebr-button-run").forEach((btn) => { 12 | btn.innerHTML = buttonText; 13 | btn.disabled = !enableCodeButton; 14 | }); 15 | } 16 | 17 | // Function to update the status message in non-interactive cells 18 | globalThis.qwebrUpdateStatusMessage = function(message) { 19 | document.querySelectorAll(".qwebr-status-text.qwebr-cell-needs-evaluation").forEach((elem) => { 20 | elem.innerText = message; 21 | }); 22 | } 23 | 24 | // Function to update the status message 25 | globalThis.qwebrUpdateStatusHeader = function(message) { 26 | qwebrStartupMessage.innerHTML = ` 27 | 28 | ${message}`; 29 | } 30 | 31 | // Function to return true if element is found, false if not 32 | globalThis.qwebrCheckHTMLElementExists = function(selector) { 33 | const element = document.querySelector(selector); 34 | return !!element; 35 | } 36 | 37 | // Function that detects whether reveal.js slides are present 38 | globalThis.qwebrIsRevealJS = function() { 39 | // If the '.reveal .slides' selector exists, RevealJS is likely present 40 | return qwebrCheckHTMLElementExists('.reveal .slides'); 41 | } 42 | 43 | // Initialize the Quarto sidebar element 44 | function qwebrSetupQuartoSidebar() { 45 | var newSideBarDiv = document.createElement('div'); 46 | newSideBarDiv.id = 'quarto-margin-sidebar'; 47 | newSideBarDiv.className = 'sidebar margin-sidebar'; 48 | newSideBarDiv.style.top = '0px'; 49 | newSideBarDiv.style.maxHeight = 'calc(0px + 100vh)'; 50 | 51 | return newSideBarDiv; 52 | } 53 | 54 | // Position the sidebar in the document 55 | function qwebrPlaceQuartoSidebar() { 56 | // Get the reference to the element with id 'quarto-document-content' 57 | var referenceNode = document.getElementById('quarto-document-content'); 58 | 59 | // Create the new div element 60 | var newSideBarDiv = qwebrSetupQuartoSidebar(); 61 | 62 | // Insert the new div before the 'quarto-document-content' element 63 | referenceNode.parentNode.insertBefore(newSideBarDiv, referenceNode); 64 | } 65 | 66 | function qwebrPlaceMessageContents(content, html_location = "title-block-header", revealjs_location = "title-slide") { 67 | 68 | // Get references to header elements 69 | const headerHTML = document.getElementById(html_location); 70 | const headerRevealJS = document.getElementById(revealjs_location); 71 | 72 | // Determine where to insert the quartoTitleMeta element 73 | if (headerHTML || headerRevealJS) { 74 | // Append to the existing "title-block-header" element or "title-slide" div 75 | (headerHTML || headerRevealJS).appendChild(content); 76 | } else { 77 | // If neither headerHTML nor headerRevealJS is found, insert after "webr-monaco-editor-init" script 78 | const monacoScript = document.getElementById("qwebr-monaco-editor-init"); 79 | const header = document.createElement("header"); 80 | header.setAttribute("id", "title-block-header"); 81 | header.appendChild(content); 82 | monacoScript.after(header); 83 | } 84 | } 85 | 86 | 87 | 88 | function qwebrOffScreenCanvasSupportWarningMessage() { 89 | 90 | // Verify canvas is supported. 91 | if(qwebrOffScreenCanvasSupport()) return; 92 | 93 | // Create the main container div 94 | var calloutContainer = document.createElement('div'); 95 | calloutContainer.classList.add('callout', 'callout-style-default', 'callout-warning', 'callout-titled'); 96 | 97 | // Create the header div 98 | var headerDiv = document.createElement('div'); 99 | headerDiv.classList.add('callout-header', 'd-flex', 'align-content-center'); 100 | 101 | // Create the icon container div 102 | var iconContainer = document.createElement('div'); 103 | iconContainer.classList.add('callout-icon-container'); 104 | 105 | // Create the icon element 106 | var iconElement = document.createElement('i'); 107 | iconElement.classList.add('callout-icon'); 108 | 109 | // Append the icon element to the icon container 110 | iconContainer.appendChild(iconElement); 111 | 112 | // Create the title container div 113 | var titleContainer = document.createElement('div'); 114 | titleContainer.classList.add('callout-title-container', 'flex-fill'); 115 | titleContainer.innerText = 'Warning: Web Browser Does Not Support Graphing!'; 116 | 117 | // Append the icon container and title container to the header div 118 | headerDiv.appendChild(iconContainer); 119 | headerDiv.appendChild(titleContainer); 120 | 121 | // Create the body container div 122 | var bodyContainer = document.createElement('div'); 123 | bodyContainer.classList.add('callout-body-container', 'callout-body'); 124 | 125 | // Create the paragraph element for the body content 126 | var paragraphElement = document.createElement('p'); 127 | paragraphElement.innerHTML = 'This web browser does not have support for displaying graphs through the quarto-webr extension since it lacks an OffScreenCanvas. Please upgrade your web browser to one that supports OffScreenCanvas.'; 128 | 129 | // Append the paragraph element to the body container 130 | bodyContainer.appendChild(paragraphElement); 131 | 132 | // Append the header div and body container to the main container div 133 | calloutContainer.appendChild(headerDiv); 134 | calloutContainer.appendChild(bodyContainer); 135 | 136 | // Append the main container div to the document depending on format 137 | qwebrPlaceMessageContents(calloutContainer, "title-block-header"); 138 | 139 | } 140 | 141 | 142 | // Function that attaches the document status message and diagnostics 143 | function displayStartupMessage(showStartupMessage, showHeaderMessage) { 144 | if (!showStartupMessage) { 145 | return; 146 | } 147 | 148 | // Create the outermost div element for metadata 149 | const quartoTitleMeta = document.createElement("div"); 150 | quartoTitleMeta.classList.add("quarto-title-meta"); 151 | 152 | // Create the first inner div element 153 | const firstInnerDiv = document.createElement("div"); 154 | firstInnerDiv.setAttribute("id", "qwebr-status-message-area"); 155 | 156 | // Create the second inner div element for "WebR Status" heading and contents 157 | const secondInnerDiv = document.createElement("div"); 158 | secondInnerDiv.setAttribute("id", "qwebr-status-message-title"); 159 | secondInnerDiv.classList.add("quarto-title-meta-heading"); 160 | secondInnerDiv.innerText = "WebR Status"; 161 | 162 | // Create another inner div for contents 163 | const secondInnerDivContents = document.createElement("div"); 164 | secondInnerDivContents.setAttribute("id", "qwebr-status-message-body"); 165 | secondInnerDivContents.classList.add("quarto-title-meta-contents"); 166 | 167 | // Describe the WebR state 168 | qwebrStartupMessage.innerText = "🟡 Loading..."; 169 | qwebrStartupMessage.setAttribute("id", "qwebr-status-message-text"); 170 | // Add `aria-live` to auto-announce the startup status to screen readers 171 | qwebrStartupMessage.setAttribute("aria-live", "assertive"); 172 | 173 | // Append the startup message to the contents 174 | secondInnerDivContents.appendChild(qwebrStartupMessage); 175 | 176 | // Add a status indicator for COOP and COEP Headers if needed 177 | if (showHeaderMessage) { 178 | const crossOriginMessage = document.createElement("p"); 179 | crossOriginMessage.innerText = `${crossOriginIsolated ? '🟢' : '🟡'} COOP & COEP Headers`; 180 | crossOriginMessage.setAttribute("id", "qwebr-coop-coep-header"); 181 | secondInnerDivContents.appendChild(crossOriginMessage); 182 | } 183 | 184 | // Combine the inner divs and contents 185 | firstInnerDiv.appendChild(secondInnerDiv); 186 | firstInnerDiv.appendChild(secondInnerDivContents); 187 | quartoTitleMeta.appendChild(firstInnerDiv); 188 | 189 | // Place message on webpage 190 | qwebrPlaceMessageContents(quartoTitleMeta); 191 | } 192 | 193 | function qwebrAddCommandHistoryModal() { 194 | // Create the modal div 195 | var modalDiv = document.createElement('div'); 196 | modalDiv.id = 'qwebr-history-modal'; 197 | modalDiv.className = 'qwebr-modal'; 198 | 199 | // Create the modal content div 200 | var modalContentDiv = document.createElement('div'); 201 | modalContentDiv.className = 'qwebr-modal-content'; 202 | 203 | // Create the span for closing the modal 204 | var closeSpan = document.createElement('span'); 205 | closeSpan.id = 'qwebr-command-history-close-btn'; 206 | closeSpan.className = 'qwebr-modal-close'; 207 | closeSpan.innerHTML = '×'; 208 | 209 | // Create the h1 element for the modal 210 | var modalH1 = document.createElement('h1'); 211 | modalH1.textContent = 'R History Command Contents'; 212 | 213 | // Create an anchor element for downloading the Rhistory file 214 | var downloadLink = document.createElement('a'); 215 | downloadLink.href = '#'; 216 | downloadLink.id = 'qwebr-download-history-btn'; 217 | downloadLink.className = 'qwebr-download-btn'; 218 | 219 | // Create an 'i' element for the icon 220 | var icon = document.createElement('i'); 221 | icon.className = 'bi bi-file-code'; 222 | 223 | // Append the icon to the anchor element 224 | downloadLink.appendChild(icon); 225 | 226 | // Add the text 'Download R History' to the anchor element 227 | downloadLink.appendChild(document.createTextNode(' Download R History File')); 228 | 229 | // Create the pre for command history contents 230 | var commandContentsPre = document.createElement('pre'); 231 | commandContentsPre.id = 'qwebr-command-history-contents'; 232 | commandContentsPre.className = 'qwebr-modal-content-code'; 233 | 234 | // Append the close span, h1, and history contents pre to the modal content div 235 | modalContentDiv.appendChild(closeSpan); 236 | modalContentDiv.appendChild(modalH1); 237 | modalContentDiv.appendChild(downloadLink); 238 | modalContentDiv.appendChild(commandContentsPre); 239 | 240 | // Append the modal content div to the modal div 241 | modalDiv.appendChild(modalContentDiv); 242 | 243 | // Append the modal div to the body 244 | document.body.appendChild(modalDiv); 245 | } 246 | 247 | function qwebrRegisterRevealJSCommandHistoryModal() { 248 | // Select the