├── .github ├── .gitignore └── workflows │ └── quarto-book-gh-pages.yaml ├── .Rprofile ├── .gitattributes ├── renv ├── .gitignore ├── settings.dcf └── activate.R ├── .Rbuildignore ├── theme-dark.scss ├── requirements.txt ├── R └── snapshot.R ├── nocheckbox.css ├── observable-background.css ├── .gitignore ├── reactivity-three-ways-quarto.Rproj ├── references.bib ├── _quarto.yml ├── stray-thoughts.qmd ├── index.log ├── README.md ├── img-filter.html ├── index.qmd ├── field-guide-python.qmd ├── deployment.qmd ├── renv.lock ├── images └── reactivity.drawio ├── penguins.csv ├── observable.qmd ├── shiny.qmd ├── LICENSE.md └── dash.qmd /.github/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | -------------------------------------------------------------------------------- /.Rprofile: -------------------------------------------------------------------------------- 1 | source("renv/activate.R") 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.qmd linguist-language=markdown 2 | -------------------------------------------------------------------------------- /renv/.gitignore: -------------------------------------------------------------------------------- 1 | library/ 2 | local/ 3 | cellar/ 4 | lock/ 5 | python/ 6 | staging/ 7 | -------------------------------------------------------------------------------- /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ^renv$ 2 | ^renv\.lock$ 3 | ^requirements\.txt$ 4 | ^\.github$ 5 | ^LICENSE\.md$ 6 | -------------------------------------------------------------------------------- /theme-dark.scss: -------------------------------------------------------------------------------- 1 | /*-- scss:defaults --*/ 2 | // Base document colors 3 | $body-bg: #141619; 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.22.2 2 | palmerpenguins==0.1.4 3 | pandas==1.4.1 4 | python-dateutil==2.8.2 5 | pytz==2021.3 6 | six==1.16.0 7 | -------------------------------------------------------------------------------- /R/snapshot.R: -------------------------------------------------------------------------------- 1 | snapshot_local <- function(...) { 2 | renv::snapshot(repos = getOption("repos")["CRAN"]) 3 | renv::use_python(name = "./venv") 4 | } 5 | 6 | -------------------------------------------------------------------------------- /nocheckbox.css: -------------------------------------------------------------------------------- 1 | .nocheckbox td:nth-child(1), .nocheckbox th:nth-child(1) { 2 | display: none; 3 | } 4 | 5 | .nocheckbox td:nth-child(2), .nocheckbox th:nth-child(2) { 6 | padding-left: 0px; 7 | } 8 | -------------------------------------------------------------------------------- /observable-background.css: -------------------------------------------------------------------------------- 1 | div.observablehq table thead tr th { 2 | background-color: var(--bs-body-bg); 3 | } 4 | 5 | input, button, select, optgroup, textarea { 6 | background-color: var(--bs-body-bg); 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rproj.user 2 | .Rhistory 3 | .RData 4 | .Ruserdata 5 | 6 | /.quarto/ 7 | _book 8 | 9 | 10 | # virtual environment 11 | venv 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | -------------------------------------------------------------------------------- /renv/settings.dcf: -------------------------------------------------------------------------------- 1 | bioconductor.version: 2 | external.libraries: 3 | ignored.packages: 4 | package.dependency.fields: Imports, Depends, LinkingTo 5 | r.version: 6 | snapshot.type: implicit 7 | use.cache: TRUE 8 | vcs.ignore.cellar: TRUE 9 | vcs.ignore.library: TRUE 10 | vcs.ignore.local: TRUE 11 | -------------------------------------------------------------------------------- /reactivity-three-ways-quarto.Rproj: -------------------------------------------------------------------------------- 1 | Version: 1.0 2 | 3 | RestoreWorkspace: Default 4 | SaveWorkspace: Default 5 | AlwaysSaveHistory: Default 6 | 7 | EnableCodeIndexing: Yes 8 | UseSpacesForTab: Yes 9 | NumSpacesForTab: 2 10 | Encoding: UTF-8 11 | 12 | RnwWeave: Sweave 13 | LaTeX: pdfLaTeX 14 | 15 | AutoAppendNewline: Yes 16 | StripTrailingWhitespace: Yes 17 | -------------------------------------------------------------------------------- /references.bib: -------------------------------------------------------------------------------- 1 | @article{knuth84, 2 | author = {Knuth, Donald E.}, 3 | title = {Literate Programming}, 4 | year = {1984}, 5 | issue_date = {May 1984}, 6 | publisher = {Oxford University Press, Inc.}, 7 | address = {USA}, 8 | volume = {27}, 9 | number = {2}, 10 | issn = {0010-4620}, 11 | url = {https://doi.org/10.1093/comjnl/27.2.97}, 12 | doi = {10.1093/comjnl/27.2.97}, 13 | journal = {Comput. J.}, 14 | month = may, 15 | pages = {97–111}, 16 | numpages = {15} 17 | } 18 | 19 | 20 | -------------------------------------------------------------------------------- /_quarto.yml: -------------------------------------------------------------------------------- 1 | project: 2 | type: book 3 | 4 | book: 5 | title: "Data-Science Reactivity: Three Ways" 6 | author: "Ian Lyttle" 7 | chapters: 8 | - index.qmd 9 | - shiny.qmd 10 | - dash.qmd 11 | - observable.qmd 12 | appendices: 13 | - field-guide-python.qmd 14 | - deployment.qmd 15 | - stray-thoughts.qmd 16 | 17 | date: today 18 | date-format: "YYYY-MM-DD" 19 | 20 | bibliography: references.bib 21 | 22 | format: 23 | html: 24 | css: 25 | - nocheckbox.css 26 | - observable-background.css 27 | theme: 28 | light: sandstone 29 | dark: [slate, theme-dark.scss] 30 | include-in-header: 31 | - img-filter.html 32 | -------------------------------------------------------------------------------- /stray-thoughts.qmd: -------------------------------------------------------------------------------- 1 | # Stray Thoughts 2 | 3 | Putting the code together, I have had a lot of stray thoughts that I want to get into the text. 4 | I'm thinking to collect them here, then remove them as they get expressed more concretely elsewhere in this book. 5 | 6 | Shiny and Observable are more opinionated than Dash. 7 | 8 | Shiny and Observable manage the reactive graph for you; Dash makes you spell it out explicitly. 9 | 10 | Being more opinionated lets you code more concisely. 11 | Being less opinionated gives you more flexibility in what you create, at the price of more code. 12 | 13 | Shiny lets you store the state at the server; Dash and Observable do not. 14 | 15 | Dash and Observable use React under the hood. 16 | 17 | Shiny and Dash both offer a UI/server framework; in Observable, it's all notebook. 18 | 19 | Observable supports (and encourages) the idea of importing functions from other published notebooks. 20 | -------------------------------------------------------------------------------- /index.log: -------------------------------------------------------------------------------- 1 | This is XeTeX, Version 3.141592653-2.6-0.999993 (TeX Live 2021) (preloaded format=xelatex 2021.7.8) 6 APR 2022 09:43 2 | entering extended mode 3 | restricted \write18 enabled. 4 | %&-line parsing enabled. 5 | **index.tex 6 | (./index.tex 7 | LaTeX2e <2021-06-01> patch level 1 8 | L3 programming layer <2021-06-18> 9 | 10 | ! LaTeX Error: File `scrreport.cls' not found. 11 | 12 | Type X to quit or to proceed, 13 | or enter new name. (Default extension: cls) 14 | 15 | Enter file name: 16 | ! Emergency stop. 17 | 18 | 19 | l.10 \usepackage 20 | {amsmath,amssymb}^^M 21 | Here is how much of TeX's memory you used: 22 | 30 strings out of 478451 23 | 543 string characters out of 5857705 24 | 295200 words of memory out of 5000000 25 | 20474 multiletter control sequences out of 15000+600000 26 | 403430 words of font info for 27 fonts, out of 8000000 for 9000 27 | 14 hyphenation exceptions out of 8191 28 | 19i,0n,29p,103b,17s stack positions out of 5000i,500n,10000p,200000b,80000s 29 | 30 | No pages of output. 31 | -------------------------------------------------------------------------------- /.github/workflows/quarto-book-gh-pages.yaml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | push: 4 | branches: main 5 | pull_request: 6 | branches: main 7 | # to be able to trigger a manual build 8 | workflow_dispatch: 9 | 10 | name: Render and deploy Book 11 | 12 | jobs: 13 | build-deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | # maybe needs this, too? 19 | - uses: r-lib/actions/setup-renv@v2 20 | 21 | - name: Install Quarto 22 | uses: quarto-dev/quarto-actions/install-quarto@v1 23 | with: 24 | # To install LaTeX to build PDF book 25 | tinytex: true 26 | # uncomment below and fill to pin a version 27 | # version: 0.9.105 28 | 29 | - name: Render book to all format 30 | # Add any command line argument needed 31 | run: | 32 | quarto render 33 | 34 | - name: Deploy 🚀 35 | # only deploy when push to main 36 | if: github.event_name != 'pull_request' 37 | uses: JamesIves/github-pages-deploy-action@v4 38 | with: 39 | # The branch the action should deploy to. 40 | branch: gh-pages 41 | # The folder the action should deploy. Adapt if you changed in Quarto config 42 | folder: _book 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reactivity-three-ways-quarto 2 | 3 | 4 | 5 | 6 | 7 | This is a [Quarto book](https://quarto.org/docs/books/). To run this on your computer, you'll need: 8 | 9 | - A recent version of [Quarto](https://quarto.org/docs/get-started/) and, although not strictly necessary, a recent version of the [RStudio IDE](https://www.rstudio.com/products/rstudio/). 10 | - A recent version of Python. 11 | I use Python 3.10, but it runs on 3.8. 12 | - The R package [renv](https://rstudio.github.io/renv/index.html). 13 | 14 | ## Restore 15 | 16 | To restore the project environment, use the R console from the project's root directory: 17 | 18 | ```{r} 19 | renv::restore() 20 | ``` 21 | 22 | ## Render 23 | 24 | To render the book, you can use the terminal: 25 | 26 | quarto render 27 | 28 | or you can use the "Render" button in the RStudio IDE. 29 | 30 | ## Image filter 31 | 32 | You may notice diagrams specified in the document like this: 33 | 34 | ![Reactivity diagram for Shiny demo-app](images/shiny-aggregate-local.svg){.filter} 35 | 36 | The `{.filter}` adds a class `"filter"` to the `` element. 37 | The class is used by a bit of added JavaScript, in [`img-filter.html`](https://github.com/ijlyttle/reactivity-three-ways-quarto/blob/main/img-filter.html), to identify images to be inverted in dark-mode. 38 | These images are line diagrams with transparent backgrounds. 39 | -------------------------------------------------------------------------------- /img-filter.html: -------------------------------------------------------------------------------- 1 | 58 | -------------------------------------------------------------------------------- /index.qmd: -------------------------------------------------------------------------------- 1 | # Preface {.unnumbered} 2 | 3 | The purpose of this book is to compare and contrast reactive data-science apps using three languages/frameworks: 4 | 5 | - R: [Shiny](https://shiny.rstudio.com/) 6 | - Python: [Dash](https://dash.plotly.com/) 7 | - JavaScript: [Observable](https://observablehq.com/@observablehq/five-minute-introduction) 8 | 9 | An example app is created for each framework: 10 | 11 | - starting with the `penguins` data-frame from [palmerpenguins](https://allisonhorst.github.io/palmerpenguins/): 12 | - show it as a table. 13 | - specify grouping columns, aggregation columns, and an aggregation function. 14 | - create an aggregated data-frame from the input, using the specification. 15 | - show the aggregated data-frame as a table. 16 | 17 | This book is written for folks who know how to develop basic Shiny apps, and wish to extend their knowledge to Dash (Python) or Observable (JavaScript). I will assume you have: 18 | 19 | - basic knowledge of how to build a Shiny app. 20 | - some familiarity with the tidyverse, in particular, dplyr. 21 | 22 | In this book: 23 | 24 | - we'll review a [Shiny app](https://ijlyttle.shinyapps.io/aggregate-local), highlighting parts of its [code](https://github.com/ijlyttle/reactivity-demo-shiny). 25 | 26 | - we'll look at a [Dash app](https://aggregate-local.herokuapp.com) and the [code](https://github.com/ijlyttle/reactivity-demo-dash). 27 | 28 | - we'll look at an Observable Notebook, where the [app is the code](https://observablehq.com/@ijlyttle/aggregate-local). 29 | 30 | 31 | The goal is to give you the confidence to take the next steps to learn more about each of these frameworks. 32 | 33 | ## Other resources {.unnumbered} 34 | 35 | Here are some resources I have found useful: 36 | 37 | - Shiny's [tutorials](https://shiny.rstudio.com/tutorial/). 38 | - Once you have built a few Shiny apps, it can be helpful to get a better sense of what makes Shiny "tick". 39 | Joe Cheng gave an outstanding tutorial at the precursor to rstudio::conf() in 2016: [Part 1](https://www.rstudio.com/resources/shiny-dev-con/reactivity-pt-1-joe-cheng/), [Part 2](https://www.rstudio.com/resources/shiny-dev-con/reactivity-pt-2/). 40 | - Hadley Wickham's [Mastering Shiny](https://mastering-shiny.org/). 41 | - Appsilon has a handy blog post: [Dash vs. Shiny](https://appsilon.com/dash-vs-shiny/). 42 | - Dash's [documentation](https://dash.plotly.com/). 43 | - For an introduction to Observable, this [tutorial page](https://observablehq.com/tutorials) is a great start. 44 | - If you are comfortable with JavaScript and want to get a quick sense of Observable: the somewhat distractingly-named [Observable's not JavaScript](https://observablehq.com/@observablehq/observables-not-javascript). 45 | -------------------------------------------------------------------------------- /field-guide-python.qmd: -------------------------------------------------------------------------------- 1 | # Field Guide to Python 2 | 3 | In this appendix, we focus on how to get up-and-running in Python, and how you can make your Python environment reprodicible. 4 | Once you have established this, RStudio have a useful [guide for how to "think" in Python](https://rstudio.github.io/reticulate/articles/python_primer.html), knowing R. 5 | 6 | 7 | ## Python installation 8 | 9 | The first order of business is to make sure that you have a recent version of Python installed. 10 | By recent, I mean one of the last two minor version; as of February 2022, these are versions 3.10 and 3.9. 11 | 12 | To check your default Python version, just type `python -V` (note the captial) at the terminal command line (not the R command line). 13 | This is what I see: 14 | 15 | ``` 16 | > python -V 17 | Python 3.10.2 18 | ``` 19 | 20 | There is a variety of strategies for managing Python on your computer, perhaps the simplest is to go to the [Python downloads page](https://www.python.org/downloads/), then go from there. 21 | 22 | ## Project management 23 | 24 | ### Git 25 | 26 | If you are creating your project directory from scratch, you will likely want to initialize a git repository in your newly-created directory: 27 | 28 | ``` 29 | > git init 30 | ``` 31 | 32 | You will also want a `.gitignore` file for your project. 33 | Here's my `.gitignore` file for the Dash demo app; I include some RStudio stuff for if/when I open the project in RStudio. 34 | 35 | ``` 36 | # RStudio stuff 37 | .Rproj.user 38 | *.Rproj 39 | .Rhistory 40 | .Rdata 41 | 42 | # virtual environment 43 | venv 44 | 45 | # Byte-compiled / optimized / DLL files 46 | __pycache__/ 47 | *.py[cod] 48 | *$py.class 49 | ``` 50 | 51 | You may wish to adapt the virtual-environment entry to your situation. 52 | 53 | ### Virtual Environment 54 | 55 | Python virtual environments are used to manage dependencies so that they are local to a project. 56 | This is a different from the classic R idea of having a single library of packages used for all projects. 57 | 58 | The idea of a project-based is also used in JavaScript (e.g. npm, yarn) and is gaining popularity in R with the [renv](https://rstudio.github.io/renv/index.html) package. 59 | In fact, this book is build using renv. 60 | 61 | The goal here is to show you how to establish and manage a virtual environment for a Dash project. 62 | 63 | In your newly-created project directory, from the terminal command-line: 64 | 65 | ``` 66 | > python -m venv ./venv 67 | ``` 68 | 69 | This creates your Python virtual environment by creating a directory in the root of your project called `venv`. 70 | The name of the directory is determined by the last argument, in this case `./venv`. 71 | There are a number of "standard" ways to name virtual environments; `"venv"` is one of them. 72 | It's really up to you and your collaborators. 73 | 74 | The important thing is to make sure that you have a `.gitignore` entry for the virtual-environment directory. 75 | 76 | Next, let's activate the environment. 77 | This tells your terminal that *this* is what you want to run when you invoke `python`. 78 | 79 | In your project directory, from the terminal command-line: 80 | 81 | ``` 82 | > source ./venv/bin/activate 83 | ``` 84 | 85 | At this point, you might want to install packages into your virtual enviromment. 86 | Which packages will depend on the particulars of your project, but you can start with Dash: 87 | 88 | ``` 89 | > pip install dash 90 | ``` 91 | 92 | Every so often, you will want to catpure which packages have been installed into your virtual environment: 93 | 94 | ``` 95 | > pip freeze > requirements.txt 96 | ``` 97 | 98 | You will want to commit `requirements.txt` to your git repository, as this contains the instructions for someone to reproduce your virtual environment. 99 | 100 | To reproduce it, a colleague (perhaps you!) will have to create and activate a virtual environment, then: 101 | 102 | ``` 103 | > pip install -r requirements.txt 104 | ``` 105 | 106 | These are the very basics for how to set up and maintain a Python project. 107 | As you gain experience, you will likely adapt these ideas to your evolving needs. 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /deployment.qmd: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | If you want to share your app with the world, there are free options for each alternative. 4 | The free route I found for Dash has proven to be more complicated than the other two. 5 | 6 | ## Shiny 7 | 8 | Not a lot of trickery here; I got an [shinyapps.io](https://www.shinyapps.io/) account and used the [deployment service](https://shiny.rstudio.com/articles/shinyapps.html) built into the RStudio IDE. 9 | 10 | ## Dash 11 | 12 | Plotly offers a [Dash Enterprise](https://plotly.com/dash) service. 13 | However, if you want deploy a publicly-available app for free, you can deploy to Heroku. 14 | 15 | The process is descibed in the [Dash deployment documentation](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps-for-free), but I found that I needed to implement a few workarounds to get things to work. 16 | Your mileage may vary. 17 | 18 | The overall process is: 19 | 20 | - Establish your Heroku account, once. 21 | - Configure your Dash app, once per app. 22 | - Create your app on Heroku, once per app. 23 | - Git-push your app to Heroku, once per deployment. 24 | 25 | ### Establish Heroku account 26 | 27 | - Sign up at [Heroku](https://signup.heroku.com/). 28 | - Install the [Heroku CLI](https://devcenter.heroku.com/categories/command-line). 29 | 30 | The Heroku CLI is used to create your (Heroku) apps and deploy them. 31 | 32 | From time to time, you will need to authenticate to Heroku from the terminal. 33 | 34 | The easiest thing to do (from the terminal) is: 35 | 36 | ``` 37 | heroku login 38 | ``` 39 | 40 | This does not work for me, so (following this [SO post](https://stackoverflow.com/questions/63363085/ip-address-mismatch-on-signing-into-heroku-cli)) I use: 41 | 42 | ``` 43 | heroku login -i 44 | ``` 45 | 46 | Heroku asks for your email (account) and password. 47 | Because I use two-factor authentication with Heroku, I need to create a token to allow me to authenticate using the CLI: 48 | 49 | - [Create an authorization](https://dashboard.heroku.com/account/applications) for Heroku CLI: 50 | - Hit the "Create authorization" button. 51 | - Provide a description, something like "Heroku CLI". 52 | - Provide an expiry duration, if you like. 53 | 54 | Heroku will generate a token that you can use to log in from the command line. 55 | 56 | 😅 57 | 58 | ### Configure Dash app 59 | 60 | Following the [Dash instructions](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps-for-free), there's a few things we need to to, once per app: 61 | 62 | - create a local git repository 63 | - use a Python virtual environment 64 | - install `gunicorn` 65 | - adapt your app's Python file 66 | - create a `Procfile` in your app repo's root directory. 67 | 68 | Using git and creating a repository may be familiar to you already; if not, [Happy Git with R](https://happygitwithr.com/) is highly recommended. 69 | 70 | To get started with Python virtual environments, see the [Appendix: Field Guide to Python](field-guide-python.html). 71 | 72 | You will have to install "gunicorn" in your virtual environment, as Heroku uses this [to serve Python apps](https://devcenter.heroku.com/articles/python-gunicorn). 73 | From the terminal: 74 | 75 | ``` 76 | pip install gunicorn 77 | ``` 78 | 79 | The next step is to define a `server` variable in your app, as Heroko (gunicorn) will need to use it. 80 | In your app's Python file, after you have defined `app`: 81 | 82 | ```py 83 | # make `server` available to Heroku 84 | server = app.server 85 | ``` 86 | 87 | Finally (at least for this section), we will need to tell Heroku how to use gunicorn to serve your app. 88 | In your repo's root directory, create a file named `Procfile` with a line like this: 89 | 90 | ``` 91 | web: gunicorn app-aggregate-local:server 92 | ``` 93 | 94 | **Note**: 95 | 96 | - `app-aggregate-local` refers to the file `app-aggregate-local.py`; use the name of *your* app file. 97 | - `server` refers to the `server` object in `app-aggregate-local.py`, the variable you just made available. 98 | 99 | ### Create Heroku app 100 | 101 | Once you have authenticated on the Heroku CLI, there's a couple of steps to create the app on Heroku: 102 | 103 | ``` 104 | heroku create aggregate-local 105 | ``` 106 | 107 | **Note**: use the name for *your* app, rather than `aggregate-local`. 108 | 109 | This command [does two things](https://devcenter.heroku.com/articles/creating-apps): 110 | 111 | - creates the app at Heroku 112 | - creates a git remote 113 | 114 | ### Git-push Heroku app 115 | 116 | When you're ready to deploy, or redeploy, it's a three-step process: 117 | 118 | - Update your `requirements.txt`, so Heroku will know how to create the Python virtual environment. 119 | - Commit your changes to git, locally. 120 | - Push your branch to Heroku. 121 | 122 | From the terminal: 123 | 124 | ``` 125 | pip freeze > requirements.txt 126 | ``` 127 | 128 | Git-commit your changes, then: 129 | 130 | ``` 131 | git push heroku main 132 | ``` 133 | 134 | Or whatever your branch happens to be named. 135 | 136 | All being well, your app should be deployed and operational. 137 | You can go to your [Heroku apps dashboard](https://dashboard.heroku.com/apps) to keep track of all of your deployed apps. 138 | 139 | ### Miscellany 140 | 141 | In the [Dash instructions](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps-for-free), there is a command they suggest running, after you have made you have made your first deployment: 142 | 143 | From the terminal: 144 | 145 | ``` 146 | heroku ps:scale web=1 147 | ``` 148 | 149 | I have read [this is not scrictly necessary](https://stackoverflow.com/a/30326031/1523865), as it is setting the value to the default. 150 | 151 | As well, the [Dash instructions](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps-for-free) assume that you have one app per git repo. 152 | 153 | If you want to have more than one app per repo, [there's a way](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-multi-procfile). 154 | For each app in a given repo, you'll need: 155 | 156 | - a separate Procfile. 157 | - a `PROCFILE` environment-variable for each Heroku app, specifying the location of its `Procfile`. 158 | - a separate Heroku git-remote. 159 | 160 | I have not done this yet, myself. 161 | 162 | ### References 163 | 164 | - [Dash deployment](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps-for-free) 165 | - [Heroku CLI Authentication](https://devcenter.heroku.com/articles/authentication) 166 | - [Heroku Python Apps](https://devcenter.heroku.com/articles/python-gunicorn) 167 | - [Heroku app creation](https://devcenter.heroku.com/articles/creating-apps) 168 | - [Stack Overflow on "IP Address Mismatch on signing into Heroku CLI"](https://stackoverflow.com/questions/63363085/ip-address-mismatch-on-signing-into-heroku-cli) 169 | - [Heroku apps dashboard](https://dashboard.heroku.com/apps) 170 | - [Heroku Multi Procfile buildpack](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-multi-procfile) 171 | 172 | ## Observable 173 | 174 | At the [Observable site](https://observablehq.com/), code is "deployed" once you hit the "Publish" button. 175 | 176 | That said, there are some interesting possibilities to [incorporate Observable into Quarto documents](https://quarto.org/docs/interactive/ojs/). 177 | -------------------------------------------------------------------------------- /renv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "R": { 3 | "Version": "4.1.2", 4 | "Repositories": [ 5 | { 6 | "Name": "CRAN", 7 | "URL": "https://cloud.r-project.org" 8 | } 9 | ] 10 | }, 11 | "Python": { 12 | "Version": "3.10.2", 13 | "Type": "virtualenv", 14 | "Name": "./venv" 15 | }, 16 | "Packages": { 17 | "Matrix": { 18 | "Package": "Matrix", 19 | "Version": "1.3-4", 20 | "Source": "Repository", 21 | "Repository": "CRAN", 22 | "Hash": "4ed05e9c9726267e4a5872e09c04587c", 23 | "Requirements": [ 24 | "lattice" 25 | ] 26 | }, 27 | "R6": { 28 | "Package": "R6", 29 | "Version": "2.5.1", 30 | "Source": "Repository", 31 | "Repository": "CRAN", 32 | "Hash": "470851b6d5d0ac559e9d01bb352b4021", 33 | "Requirements": [] 34 | }, 35 | "Rcpp": { 36 | "Package": "Rcpp", 37 | "Version": "1.0.8", 38 | "Source": "Repository", 39 | "Repository": "CRAN", 40 | "Hash": "22b546dd7e337f6c0c58a39983a496bc", 41 | "Requirements": [] 42 | }, 43 | "RcppTOML": { 44 | "Package": "RcppTOML", 45 | "Version": "0.1.7", 46 | "Source": "Repository", 47 | "Repository": "CRAN", 48 | "Hash": "f8a578aa91321ecec1292f1e2ffadeda", 49 | "Requirements": [ 50 | "Rcpp" 51 | ] 52 | }, 53 | "base64enc": { 54 | "Package": "base64enc", 55 | "Version": "0.1-3", 56 | "Source": "Repository", 57 | "Repository": "CRAN", 58 | "Hash": "543776ae6848fde2f48ff3816d0628bc", 59 | "Requirements": [] 60 | }, 61 | "bslib": { 62 | "Package": "bslib", 63 | "Version": "0.3.1", 64 | "Source": "Repository", 65 | "Repository": "CRAN", 66 | "Hash": "56ae7e1987b340186a8a5a157c2ec358", 67 | "Requirements": [ 68 | "htmltools", 69 | "jquerylib", 70 | "jsonlite", 71 | "rlang", 72 | "sass" 73 | ] 74 | }, 75 | "cli": { 76 | "Package": "cli", 77 | "Version": "3.2.0", 78 | "Source": "Repository", 79 | "Repository": "CRAN", 80 | "Hash": "1bdb126893e9ce6aae50ad1d6fc32faf", 81 | "Requirements": [ 82 | "glue" 83 | ] 84 | }, 85 | "crayon": { 86 | "Package": "crayon", 87 | "Version": "1.5.1", 88 | "Source": "Repository", 89 | "Repository": "CRAN", 90 | "Hash": "8dc45fd8a1ee067a92b85ef274e66d6a", 91 | "Requirements": [] 92 | }, 93 | "digest": { 94 | "Package": "digest", 95 | "Version": "0.6.29", 96 | "Source": "Repository", 97 | "Repository": "CRAN", 98 | "Hash": "cf6b206a045a684728c3267ef7596190", 99 | "Requirements": [] 100 | }, 101 | "dplyr": { 102 | "Package": "dplyr", 103 | "Version": "1.0.7", 104 | "Source": "Repository", 105 | "Repository": "CRAN", 106 | "Hash": "36f1ae62f026c8ba9f9b5c9a08c03297", 107 | "Requirements": [ 108 | "R6", 109 | "ellipsis", 110 | "generics", 111 | "glue", 112 | "lifecycle", 113 | "magrittr", 114 | "pillar", 115 | "rlang", 116 | "tibble", 117 | "tidyselect", 118 | "vctrs" 119 | ] 120 | }, 121 | "ellipsis": { 122 | "Package": "ellipsis", 123 | "Version": "0.3.2", 124 | "Source": "Repository", 125 | "Repository": "CRAN", 126 | "Hash": "bb0eec2fe32e88d9e2836c2f73ea2077", 127 | "Requirements": [ 128 | "rlang" 129 | ] 130 | }, 131 | "evaluate": { 132 | "Package": "evaluate", 133 | "Version": "0.14", 134 | "Source": "Repository", 135 | "Repository": "CRAN", 136 | "Hash": "ec8ca05cffcc70569eaaad8469d2a3a7", 137 | "Requirements": [] 138 | }, 139 | "fansi": { 140 | "Package": "fansi", 141 | "Version": "1.0.2", 142 | "Source": "Repository", 143 | "Repository": "CRAN", 144 | "Hash": "f28149c2d7a1342a834b314e95e67260", 145 | "Requirements": [] 146 | }, 147 | "fastmap": { 148 | "Package": "fastmap", 149 | "Version": "1.1.0", 150 | "Source": "Repository", 151 | "Repository": "CRAN", 152 | "Hash": "77bd60a6157420d4ffa93b27cf6a58b8", 153 | "Requirements": [] 154 | }, 155 | "fs": { 156 | "Package": "fs", 157 | "Version": "1.5.2", 158 | "Source": "Repository", 159 | "Repository": "CRAN", 160 | "Hash": "7c89603d81793f0d5486d91ab1fc6f1d", 161 | "Requirements": [] 162 | }, 163 | "generics": { 164 | "Package": "generics", 165 | "Version": "0.1.1", 166 | "Source": "Repository", 167 | "Repository": "CRAN", 168 | "Hash": "3f6bcfb0ee5d671d9fd1893d2faa79cb", 169 | "Requirements": [] 170 | }, 171 | "glue": { 172 | "Package": "glue", 173 | "Version": "1.6.1", 174 | "Source": "Repository", 175 | "Repository": "CRAN", 176 | "Hash": "de07842fc27ebf60e1102091c0c85e47", 177 | "Requirements": [] 178 | }, 179 | "here": { 180 | "Package": "here", 181 | "Version": "1.0.1", 182 | "Source": "Repository", 183 | "Repository": "CRAN", 184 | "Hash": "24b224366f9c2e7534d2344d10d59211", 185 | "Requirements": [ 186 | "rprojroot" 187 | ] 188 | }, 189 | "highr": { 190 | "Package": "highr", 191 | "Version": "0.9", 192 | "Source": "Repository", 193 | "Repository": "CRAN", 194 | "Hash": "8eb36c8125038e648e5d111c0d7b2ed4", 195 | "Requirements": [ 196 | "xfun" 197 | ] 198 | }, 199 | "htmltools": { 200 | "Package": "htmltools", 201 | "Version": "0.5.2", 202 | "Source": "Repository", 203 | "Repository": "CRAN", 204 | "Hash": "526c484233f42522278ab06fb185cb26", 205 | "Requirements": [ 206 | "base64enc", 207 | "digest", 208 | "fastmap", 209 | "rlang" 210 | ] 211 | }, 212 | "jquerylib": { 213 | "Package": "jquerylib", 214 | "Version": "0.1.4", 215 | "Source": "Repository", 216 | "Repository": "CRAN", 217 | "Hash": "5aab57a3bd297eee1c1d862735972182", 218 | "Requirements": [ 219 | "htmltools" 220 | ] 221 | }, 222 | "jsonlite": { 223 | "Package": "jsonlite", 224 | "Version": "1.7.3", 225 | "Source": "Repository", 226 | "Repository": "CRAN", 227 | "Hash": "68c37fd8f863c6273dcd24928c17d6e1", 228 | "Requirements": [] 229 | }, 230 | "knitr": { 231 | "Package": "knitr", 232 | "Version": "1.37", 233 | "Source": "Repository", 234 | "Repository": "CRAN", 235 | "Hash": "a4ec675eb332a33fe7b7fe26f70e1f98", 236 | "Requirements": [ 237 | "evaluate", 238 | "highr", 239 | "stringr", 240 | "xfun", 241 | "yaml" 242 | ] 243 | }, 244 | "later": { 245 | "Package": "later", 246 | "Version": "1.3.0", 247 | "Source": "Repository", 248 | "Repository": "CRAN", 249 | "Hash": "7e7b457d7766bc47f2a5f21cc2984f8e", 250 | "Requirements": [ 251 | "Rcpp", 252 | "rlang" 253 | ] 254 | }, 255 | "lattice": { 256 | "Package": "lattice", 257 | "Version": "0.20-45", 258 | "Source": "Repository", 259 | "Repository": "CRAN", 260 | "Hash": "b64cdbb2b340437c4ee047a1f4c4377b", 261 | "Requirements": [] 262 | }, 263 | "lifecycle": { 264 | "Package": "lifecycle", 265 | "Version": "1.0.1", 266 | "Source": "Repository", 267 | "Repository": "CRAN", 268 | "Hash": "a6b6d352e3ed897373ab19d8395c98d0", 269 | "Requirements": [ 270 | "glue", 271 | "rlang" 272 | ] 273 | }, 274 | "magrittr": { 275 | "Package": "magrittr", 276 | "Version": "2.0.1", 277 | "Source": "Repository", 278 | "Repository": "CRAN", 279 | "Hash": "41287f1ac7d28a92f0a286ed507928d3", 280 | "Requirements": [] 281 | }, 282 | "palmerpenguins": { 283 | "Package": "palmerpenguins", 284 | "Version": "0.1.0", 285 | "Source": "Repository", 286 | "Repository": "CRAN", 287 | "Hash": "9a455031890b2adf20e19784a114ddd4", 288 | "Requirements": [] 289 | }, 290 | "pillar": { 291 | "Package": "pillar", 292 | "Version": "1.6.4", 293 | "Source": "Repository", 294 | "Repository": "CRAN", 295 | "Hash": "60200b6aa32314ac457d3efbb5ccbd98", 296 | "Requirements": [ 297 | "cli", 298 | "crayon", 299 | "ellipsis", 300 | "fansi", 301 | "lifecycle", 302 | "rlang", 303 | "utf8", 304 | "vctrs" 305 | ] 306 | }, 307 | "pkgconfig": { 308 | "Package": "pkgconfig", 309 | "Version": "2.0.3", 310 | "Source": "Repository", 311 | "Repository": "CRAN", 312 | "Hash": "01f28d4278f15c76cddbea05899c5d6f", 313 | "Requirements": [] 314 | }, 315 | "png": { 316 | "Package": "png", 317 | "Version": "0.1-7", 318 | "Source": "Repository", 319 | "Repository": "CRAN", 320 | "Hash": "03b7076c234cb3331288919983326c55", 321 | "Requirements": [] 322 | }, 323 | "processx": { 324 | "Package": "processx", 325 | "Version": "3.5.2", 326 | "Source": "Repository", 327 | "Repository": "CRAN", 328 | "Hash": "0cbca2bc4d16525d009c4dbba156b37c", 329 | "Requirements": [ 330 | "R6", 331 | "ps" 332 | ] 333 | }, 334 | "ps": { 335 | "Package": "ps", 336 | "Version": "1.6.0", 337 | "Source": "Repository", 338 | "Repository": "CRAN", 339 | "Hash": "32620e2001c1dce1af49c49dccbb9420", 340 | "Requirements": [] 341 | }, 342 | "purrr": { 343 | "Package": "purrr", 344 | "Version": "0.3.4", 345 | "Source": "Repository", 346 | "Repository": "CRAN", 347 | "Hash": "97def703420c8ab10d8f0e6c72101e02", 348 | "Requirements": [ 349 | "magrittr", 350 | "rlang" 351 | ] 352 | }, 353 | "quarto": { 354 | "Package": "quarto", 355 | "Version": "1.0", 356 | "Source": "Repository", 357 | "Repository": "CRAN", 358 | "Hash": "0bca152d8e08143b7525b825942eba7d", 359 | "Requirements": [ 360 | "jsonlite", 361 | "later", 362 | "processx", 363 | "rmarkdown", 364 | "rstudioapi", 365 | "yaml" 366 | ] 367 | }, 368 | "rappdirs": { 369 | "Package": "rappdirs", 370 | "Version": "0.3.3", 371 | "Source": "Repository", 372 | "Repository": "CRAN", 373 | "Hash": "5e3c5dc0b071b21fa128676560dbe94d", 374 | "Requirements": [] 375 | }, 376 | "renv": { 377 | "Package": "renv", 378 | "Version": "0.15.2", 379 | "Source": "Repository", 380 | "Repository": "CRAN", 381 | "Hash": "206c4ef8b7ad6fb1060d69aa7b9dfe69", 382 | "Requirements": [] 383 | }, 384 | "reticulate": { 385 | "Package": "reticulate", 386 | "Version": "1.24", 387 | "Source": "Repository", 388 | "Repository": "CRAN", 389 | "Hash": "ffdf27627a3c1537478073c43b6e7980", 390 | "Requirements": [ 391 | "Matrix", 392 | "Rcpp", 393 | "RcppTOML", 394 | "here", 395 | "jsonlite", 396 | "png", 397 | "rappdirs", 398 | "withr" 399 | ] 400 | }, 401 | "rlang": { 402 | "Package": "rlang", 403 | "Version": "1.0.2", 404 | "Source": "Repository", 405 | "Repository": "CRAN", 406 | "Hash": "04884d9a75d778aca22c7154b8333ec9", 407 | "Requirements": [] 408 | }, 409 | "rmarkdown": { 410 | "Package": "rmarkdown", 411 | "Version": "2.11.3", 412 | "Source": "GitHub", 413 | "RemoteType": "github", 414 | "RemoteHost": "api.github.com", 415 | "RemoteRepo": "rmarkdown", 416 | "RemoteUsername": "rstudio", 417 | "RemoteRef": "HEAD", 418 | "RemoteSha": "69e6f983fdd9fca18284509eb6fed8fc4c0fc3f7", 419 | "Hash": "afb22cea717afc7d2f93644fd228c76d", 420 | "Requirements": [ 421 | "bslib", 422 | "evaluate", 423 | "htmltools", 424 | "jquerylib", 425 | "jsonlite", 426 | "knitr", 427 | "stringr", 428 | "tinytex", 429 | "xfun", 430 | "yaml" 431 | ] 432 | }, 433 | "rprojroot": { 434 | "Package": "rprojroot", 435 | "Version": "2.0.3", 436 | "Source": "Repository", 437 | "Repository": "CRAN", 438 | "Hash": "1de7ab598047a87bba48434ba35d497d", 439 | "Requirements": [] 440 | }, 441 | "rstudioapi": { 442 | "Package": "rstudioapi", 443 | "Version": "0.13", 444 | "Source": "Repository", 445 | "Repository": "CRAN", 446 | "Hash": "06c85365a03fdaf699966cc1d3cf53ea", 447 | "Requirements": [] 448 | }, 449 | "sass": { 450 | "Package": "sass", 451 | "Version": "0.4.0", 452 | "Source": "Repository", 453 | "Repository": "CRAN", 454 | "Hash": "50cf822feb64bb3977bda0b7091be623", 455 | "Requirements": [ 456 | "R6", 457 | "fs", 458 | "htmltools", 459 | "rappdirs", 460 | "rlang" 461 | ] 462 | }, 463 | "stringi": { 464 | "Package": "stringi", 465 | "Version": "1.7.6", 466 | "Source": "Repository", 467 | "Repository": "CRAN", 468 | "Hash": "bba431031d30789535745a9627ac9271", 469 | "Requirements": [] 470 | }, 471 | "stringr": { 472 | "Package": "stringr", 473 | "Version": "1.4.0", 474 | "Source": "Repository", 475 | "Repository": "CRAN", 476 | "Hash": "0759e6b6c0957edb1311028a49a35e76", 477 | "Requirements": [ 478 | "glue", 479 | "magrittr", 480 | "stringi" 481 | ] 482 | }, 483 | "tibble": { 484 | "Package": "tibble", 485 | "Version": "3.1.6", 486 | "Source": "Repository", 487 | "Repository": "CRAN", 488 | "Hash": "8a8f02d1934dfd6431c671361510dd0b", 489 | "Requirements": [ 490 | "ellipsis", 491 | "fansi", 492 | "lifecycle", 493 | "magrittr", 494 | "pillar", 495 | "pkgconfig", 496 | "rlang", 497 | "vctrs" 498 | ] 499 | }, 500 | "tidyselect": { 501 | "Package": "tidyselect", 502 | "Version": "1.1.1", 503 | "Source": "Repository", 504 | "Repository": "CRAN", 505 | "Hash": "7243004a708d06d4716717fa1ff5b2fe", 506 | "Requirements": [ 507 | "ellipsis", 508 | "glue", 509 | "purrr", 510 | "rlang", 511 | "vctrs" 512 | ] 513 | }, 514 | "tinytex": { 515 | "Package": "tinytex", 516 | "Version": "0.36", 517 | "Source": "Repository", 518 | "Repository": "CRAN", 519 | "Hash": "130fe4c61e55b271a2655b3a284a205f", 520 | "Requirements": [ 521 | "xfun" 522 | ] 523 | }, 524 | "utf8": { 525 | "Package": "utf8", 526 | "Version": "1.2.2", 527 | "Source": "Repository", 528 | "Repository": "CRAN", 529 | "Hash": "c9c462b759a5cc844ae25b5942654d13", 530 | "Requirements": [] 531 | }, 532 | "vctrs": { 533 | "Package": "vctrs", 534 | "Version": "0.3.8", 535 | "Source": "Repository", 536 | "Repository": "CRAN", 537 | "Hash": "ecf749a1b39ea72bd9b51b76292261f1", 538 | "Requirements": [ 539 | "ellipsis", 540 | "glue", 541 | "rlang" 542 | ] 543 | }, 544 | "withr": { 545 | "Package": "withr", 546 | "Version": "2.5.0", 547 | "Source": "Repository", 548 | "Repository": "CRAN", 549 | "Hash": "c0e49a9760983e81e55cdd9be92e7182", 550 | "Requirements": [] 551 | }, 552 | "xfun": { 553 | "Package": "xfun", 554 | "Version": "0.29", 555 | "Source": "Repository", 556 | "Repository": "CRAN", 557 | "Hash": "e2e5fb1a74fbb68b27d6efc5372635dc", 558 | "Requirements": [] 559 | }, 560 | "yaml": { 561 | "Package": "yaml", 562 | "Version": "2.2.1", 563 | "Source": "Repository", 564 | "Repository": "CRAN", 565 | "Hash": "2826c5d9efb0a88f657c7a679c7106db", 566 | "Requirements": [] 567 | } 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /images/reactivity.drawio: -------------------------------------------------------------------------------- 1 | 7Vxtd6I6EP41fqyHd/RjbWt3z213e7Znb3vvl54UItKNhIXY6v76TSC8K6JFkBY/VDIMCYTnmZlkxg7ki8Xq2gPu/BabEA0kwVwN5MuBJImioNEvJlmHEl2TQoHl2SZXSgT39h/IhQKXLm0T+hlFgjEitpsVGthxoEEyMuB5+C2rNsMoO6oLLFgQ3BsAFaUPtknm8XMJyYkv0LbmfOiRyk8sQKTMBf4cmPgtJZKvBvKFhzEJjxarC4jY5EXzEl433XI2vjEPOqTKBTMdPekv7sO/Yyzdf32a/Pn28utM4q/HJ+voiaFJJ4A3sUfm2MIOQFeJdJJIbzB2qZpIhS+QkDV/f2BJMBXNyQLxs3Blk8fU8X/0WBiqvHXJ4CJEjXWqcQc9ewEJ9CKZQ7z1Y7qR6ok1g67Gw5EYC9Zp7Xx/M+yQKVjYiGn9wM+Y3rgk3GIH85P3eOkZ7JnmhFDISap8Tv/QSWZ/mII/tDC2EASu7Q8NvAhOGH6gOp2FXdPDuHNVmvDuoWOeM4TSzh3swFAytRHi9+a7wLAd6wbO2PvVE8kPDjcmKqKAA8OPbnzbq1f5qyfAsyApURT1UJEBIzUER9k1xHRCPfqQggcRIPZrljmAE9CK9eJL77BNb1oSuLFQFM4UbipUXc12ET4SvypBOp1EsE6puUzBLxlH3TzOtKK+Jpbry+I79dWMPj0InzBqpeY8EQVk34f4+gHE9/DSMaHJedybgc9lBpTeDDRqBsYNmAFl9AHNQMYIKGr3TUC97P7nm3QOv1z/LzsTe2WNFE1+1M7EZsgtSw2RW9+T3HqpDy7oq7Ja3WcXrxY2jxY/Xvi6CrNQF+vVCD6dZP0pM9UE/jyeo2pRiihUM1ANuHhFasYKaKMCO4dy+lOJDUXjcmDkUBetogjpFaAlnxq64ncQBuaT7bgFzqW4EW8OZEDEGgDZlkOPvTDam7xCj9gGQOdcvrBNM+An7cJlHS9WFtuKGYa7H1L4zXqN0GVGTHMwMeZJnNwmr3zi4V/wAiPsBXMj68GHDUzJlZKbwadgjjaExMW4eSuD2JzCVSnit/ivGMlvqc2hSGee2heShe0kyeBwb9DpBdAR8Ixgj7hGEDfBhNBhM0u15lGotI3CaG3UcERRq4PtTlxRUyBVKaIoWy2cTkCR8/y6kgN6TcuK/Di5NfpO/dwy5DhrelXqcnRfL6czvDllgh8e3cujdsm4N4fyzmvHvlhevxEORZOaiqoGkoYIBwo9MwP8rWi/lyyFN8kCKhbTI4t9Gxj5TxalmRt1NAgxFSm8L1hDQYBRV6wmpoM1ITAPhEICO5Gr3BIibQ+qNgZPB+5pVw+UlHEOa0LrgZLSG+cuBFzvsMfjjtnj3GaMvmOrdMPmTQP2eFyfPRZMQMAZu+zM94zMNSHOGMqmg20IC/AVoyvB1mSz3X9e0gWi09v81my+rrVt85VOb7f3ufbacu2Rxd/tGuSTcA2j8uXrTv1dob2ez4Ptq99IilzqqusBlnUUv2NQ4DM6H8XzBI2aNmeP7mv0XM2G2r6vkQtojXNQAR76jEDXc1B50LWfg1KKic8wB9Uj7mMirv180ycoX/3c2yhRSdTOWDmqbqkvVn4fMtX6wsUwWFu6SQVJv4vQUGQnjluM7DbmXMelFg+iZ/zWJWOXMXVVi3SbLZQtzQy1vDTPF6CqIyGHsGNklrWuLoVTxvPZS+xmvzg+ogkt1Hq3bkLF+uCbSuoea5uld8yVUrpNrn43oqrGgK9ZozhbOkaP3naTU1Lb6D3kB+B94WZXCzerLqobK9zMZXR04Ti/B9O03DjyjiKHnH5pkUMxT5VPC4yO9vuuzXFOeZHREdaKEZ/FMj5XpPMJLPLa21O6HN3hn1/dq9vvizft5mGtzeSfZ339QEes/cnUDxznt/ofrn5gr1/tjsTNWy67fFXxNiSFoT3r93J9HewhaDP5n0KhevKfmeSrvw==7VtdU6M8FP41vbQD4au9tGrX2XFnfdeL3b1yIkRgNxA2pGr99W8C4du2oFDasb1QcjgECM/znJNDmGgXwcsXCiPvG3EQngDFeZlolxMAVFUx+T9hWacWywSpwaW+I50Kw53/iqRRkdaV76C44sgIwcyPqkabhCGyWcUGKSXPVbdHgqtnjaCLGoY7G+Km9afvMC+/L6XYcY1815OnnhlyRwAzZ2mIPeiQ55JJu5poF5QQlm4FLxcIi8HLxuUP+P6VhOc/2fz6Hl/6i5v/rNlZ2tmyyyH5LVAUsn67ls8yZutsvJDDh082CWUecUkI8VVhXRTWG0Ii7qZy4x/E2Fo+fbhihJs8FmC5F7347Fdp+zffVqaGbF0KsClZY11q3CLqB4ghmtlCRte/yo1ST6KZdDWfztTcsC571/t7JCFbwsDHwusHeSD8woHyjYRE7rwjK2qLe/IY44AFhnbO//AxFn+EQzx1CXExgpEfT20SJDvsOHFdPqZd8828cwMsZPcodM4FvnnnIQlRaln6GMtriyNo+6F7gx7FM7cKyw8JVmFqiQyJoDi7m41wkFBnkLpoW4dW6ifAUqKZBN4XRPggU37jCkUYMv+pykUoKe3mfvmht8TnNwIUKT+aLi9Iig8wjWoX6R3Jowrw84GF65JbJBziLecx3z7P8n3+fCO9gqxVGpPClPCzA1f1d3CVklXoIEdSb0TmHjLTNqhKRVMMo52ijEXIjFtDMzKPhkMz0urIyJq/pihb/YHyQX9rh79lbPOvKsRO3dNmSnWUUzw0RrkvqTFOUjOM1Dgw9vIxapfNqMphK4+q70d5jFlDEaZa6QfMVhRpMnVPOYa+QTk3XVfd31D2kGPIvO4J4hXKhvEBo3s/jBqSUKJuPjWqYFw0IPbdkG/TNGldPCHKfD47O5f2wHecRD54F5HoOHhxxUR0ms79QPpf9JqB38mEICTM9op0f0zax4ySv+iCYEKTsdGs5CdOzLlfsjvJr6GWMrNfEMb4aSv5fznbb04JupFeDD7KZvZvk1TuNWvhDmSz4ufSHDoDqFeaPmu1QFXmdSVKdUXmbJSQ1KtCH09g6ikS9x2SsvrRwYSkWujQ6+jvKxmuhT5d2ZEMz0YIHfNjzhn7JXqFTIfM+p4Jqo6cM3ZOyeqTOm1HSqaMwKtsUEs52QSYmEn0iJo4lE/F/LcSxehFFWW5mW+54r9NcHzvcu5FWUeTFGiZw8dSPZykJ31lemo51VMSzWAcEiTMguqGBGtzSvZm6tVnmbV9mlWvpmhg7DRLVRsP/9PK+GdSbnBkyj3fqsRd/TuW4wzQrkTQWwgA/YUAxYEMnonDzmJqV45JUSwwvJxswm+C3hy7BXIXb4eahxWf0YanMHNYYSZnw3hhRj9WREPXHQTONn/mIvIMAuik0VORahwI6/Wp9vgQNhsQTmulCUJOtdKBa6WHURcdP2EHPU4P96ukyTuF9EofaCGiJ20dOz0Yv9ifzRd6rnkMFbtPqej7Kx6jx3FwtJOrx1VonzB9eJjOc9XxMK01Hv7pbelne1s6ckmv+ba0njvP97OYVze6LebtVhs01VryZNXuauClemD7smCEH8jzMCxXt7G8Jcn3XOZuv5xW6ZsTH9Pzo16QefpqY9ivNjIB36305iBK31Wg6ys5d728qfvXXtPvEGhLq67EBlYt69oQdppKr+sCom/OXvoX+6fX4Ob6n+79fQpeb/E1jdT1/Gycl7M9fh5xyNQ91Dyu9aKa3mNWu7ehwGxHqIP7BKT+iUbVf4es1I/Ol2t/WAp4s/ioNHUvPs3Vrv4H7VrbUuM4EP2aPEI5duyER0II7CzMpJhadtiXLWEptnZsyyPLIebrVzffImcIEANFJQ+J1JJaVvfpo5acgXMWry8oSMNrAlE0sC24HjizgW0Ph5bHf4SkUJKxZytBQDHUnWrBd/yIlNDVwhxDlLX6MUIihtO20CdJgnzWkgFKyUO725JE7UlTECBD8N0HkSn9G0MWVsuy6oZLhINQTz1xdUMMys5akIUAkoeGyDkfOGeUEKZK8foMRcJ2pVlicvcHcta365//4vFoPp6dBGdHStn8OUOqJVCUsBerviimzmzxxf8rTm6vAP0aLpJHPcRagSjX9iI5S3OmV8yK0owhiyNeGg6caWUHi1cgyEIEdQVEOEh4mSpzTleIMsxdcarlMYZQKBQqUqE4XgcCdMfK0bb6FVqLUv1aFxLCfOGOk+OJeIYlSdgcxDgSHW/IPWGEP+81SYhu/E5y6ospQsY40mzXOeVf3DjiS3TIjgNCggiBFGfHPollg5/JrvOlUs2LlXLXnmr1GaPkJzojEaHSNs5YfsTEOIoacig/XE5JnkBpJWm/FPg4CaaEMT6tWp6W3WgcjmvRFVqWkh1hoOEijI/WjSDQsLhAJEaM8tVZunWiUaAD3Bvp+kMjXsogCBuh4mgZ0CEaVJprGPKCRuIzUDk0UDmwvYhp1woWAL5q8H7lIvymbQhUYl4KxC9OJKaVjoECQNn2OqhH0j37QvqwCXVLIocBhkmiq9sAth2SndBrYKoDeG8EM9d7b5jZ+4OZBQEDR2LYUUb91hhFQIJ+5oNt1COJp6KdmnSm3XCmCPgMr1D5uPe0BnMPGPc5EBDtCeWysidK/RC49qw3xLX3a/Ht4Xwc3MymGAbX+Zdr+Ge5qTfcjSDPiXSVUBaSgCQgOq+lG/as+1wRkmrhf4ixQud3IGdEYruCEVpj9qNRvhMeP3Z1bVbu47JSNCoLRDFftsCXorh33NlRAk8VYmcJSZCSzLGwfjs6hs+Gk/DAb8FEUQRkTLcS4A5o6KELgiVHaRA6ow12HbttFQzQADE9qpkmbiiyn1KUSfsbiiRSq/W8HLzm3o9EWe2Cm7jmEcraMNx1X6Yow4/gXioSrk3FeuQK3enAnQlNHOKZQvvQoCgNjyY7laLf4bcvCjKc5pgcNHFNCrL7oiD7QEH9UNDHZh1vuCfWMRT1zDqOwTqQW6bgaDxQTjflOO5mOu8YlOO9JeWMDBfiOEYQA4YOTtzRiZ5lOnHSkbr25kTX3P3XKbd5Jnd/ixXpwZm7nq87coDOc8g+nHn3T/jt6vb07uHEtgLnMs+u5l2Xi4bvDnctnT7eFR5bsTDy3vFO73QxW91ejhdfR6v75WgVPc7j+H0OpR85k+PupcWPZuWuWZHpqutW1aLZuJmxdmZ7u3OMytKeiuS9Z4/VTckzkz5uVlA0umnafvY825JRt3yO7v68oJ5gx9HObjnxC1LZzkAzbzXzjLMOF5H7DFHONZ9kB90/a3rDzXTINVlz2FNS2+lM9wWs6efcySVp1hRqve5QXR6WOw7V+p3ck+fq3c62L2axrOTybcZ0emGxkXFhNnnTeDePrq/Lsg4vb9v53l5f3vaf6XWdw9820/O2bkAQLXGCP9FNbv9bkOtNTHc6PZ3iOt05Nt2Zws9zqdJDRLrtBLDrIO7uJ4ng1fr/R2pDqf/E5Zz/Dw==7V1bV6M6GP01PuriFqCPjo4z64yeNWecsxyfXLRQQIH00NSqv/6EltBCIpQWQqqZB6ekgXLZ2d/+Lgkn+kX88i11ZsENdL3oRFPclxP98kTTVFUx8X9Zy+u6xTK1dYOfhm7eadNwG755eaOSty5C15uXOiIIIxTOyo0TmCTeBJXanDSFy3K3KYzKvzpzfI9quJ04Ed16F7ooKK5L2Xzx3Qv9IP9pG+RfxA7pnDfMA8eFy60m/euJfpFCiNaf4pcLL8puHrkv//g/X36/vcZvcWwod1bgf38NTtcHu2qzS3EJqZegbg+dP8tnJ1rk92sxcx3kPYTJbIEepmHkPSRO7OV3AL2S24qPjZ8g3viyDELk3c6cSfbNEoMItwUojvCWij9OYYKunDiMMvz8gmOIID7SDUxg/uUtXKSrfQOEMCY0oJ/jP/gysj9Zh/mZD6Efec4snJ9NYLz6YjJfdb2arg+NPxYHB9oXcvgwii5gBNPVOevj8Rg3zlEKn7ytZsuysmZ8BWHi/4b4/C8BKDoS0GTXsuNzyJ/Xs5ci72ULhflz+ebB2EMpPmkl/1azRutd8jFm5ohbbgBr5E3BFlRJm5MPEb848AYG+EOOhBao0BmoiKDjnmJUUEhI4SJxPTe/RU1oKD8R13XffyJOFPoJboi8KRoYSNmjDDGnnOenhDKUEMhcr87vEhNefwjRywAxaICoLIQULHcIRPT7x7+v3n799e+jff7t8jE14qtpzjXbEMm4gkkVrQDyDhYquJlOp2IBpAwFwIBMHLpudkfKRIOHWn+gMcqgsWjQ2AzMaB1A5t57mb4+OW/u/dtdtIwfv7jXi1OLNiIuNtX5JkxRAH2YONHXTeuXMnY2fa7h6vZljY8eQq+57nAWCJbx5L2E6M/W53v8WTkD+dZldqcUsvFKNhJ8uX+2N7b2yjY3u622yH5DAtBL3PNMLWV3cuYlv4MwWbdehdlTWp0fNbaU1T/K1GmtMTknF/bes88xhZzU91BNP2PdLwNGLcJTL3JQ+FzWeCzErnbFd8Z53eowgyG+11tH/pk1bAaOOiqPHFWtSKum/pZSGS3rM9iMneJS9h9ONANjCY2yxyUJWEwCLqwxBwZOL9T/9Ke7pXZ3MYlvfo7G8+cfRNe1YuDJIn0usLNBkiLp+DjouE69bdNxnXvYHR0fBF8y/ii3ZI4ctJhLz2QIz8RQm10TjUFyVl8kR4c0JkEYufi6pWEUxjBqg7kmTMzs45pIw3jchrHO4G0bxjqS4W8Yma4ATXkyUCeIOVR1jvZw6j780J4s7dKNg7fJ+a/5P9b16WgPbhMp7PKJyMx15kFx1/dnNiZH6DSzMeHSObPtFYEBoDai0tifSIVdIzat+9v1/TUb1PU/OCLEfHT6sQ/1T6VcRBjsui3EaDeJ8eMZP6WTnK6DHOkjiuojqoysONf8lS2dxCOl2o7Z1aDZtY5hBHESDYrv8A2Zlyp98gQSHUmV1T4dElu12kcFNLH1Ve7DVAF0JtGFy2QVWR+jRBb9CFL0s3PVT2+hhKOp4JDG691nSGpwuccBDkIenfpLHiZROHk6MOsnBXuHgr1CVxyTOnUIZpi1B5ZJkxqnR43Ds6SZCQbwvsaR+kaQXAnX2gEmSkwaJTImJJKJ0QSzMfvEhKQiPlAR7xvS6lhJM2oF6kil4xg7FUTXKykqvTr3Y32i+V4V3HeQfVLpMEI+lww548hr0Fxi6qtaU07rK15qSmfQHlc1pdL+2OohSy0lipbSGUFFppYa9YYR2v+awGgRJ9JlF1ZP6fbAekrdZ4qCFFQfQ1ARBdGoqAi18JZUhspZUtE5O+mPisyfBUAG409zCP6UXNgWYs0U189MWIriDGplgd0ojubKqgCtCss1/VMHalsrRrm5o/pKTIrDy5We/VRiarR/hLXv/NTHQ006SEI4SAYjI8HXQdJoBwnOUAilgySwgR8NbOBJSEd8B6lU3ywVQvfh54GLvOkqboLNShV3k5RoPTfELv8O0Bvmhtjs8+pXAdDl5euPktoFpXagDUztGu3tr0Sj4/tSMoogGQEj7cJZMtJlLFIyCs4rJsdVf9iSUaXAISXj55SMpmiSUSlLs12jT60nGFZ+h0jV9yRjtT8fyUhXn0nJKDa1MxZ34CsZLQoy00UykZJRFMloMhLqfEtaDe1YrP++RvLDqoaurb89rPVvbbS1shG2GtboqPbnY7RtabSPy2hbQ+foyflsQWa8QAgm0myLYratXSM9/ZntoymEkwtMdG6piVtxNJa6kvmxG9ZLfy9T1O/qWPT8BDmHWHRjbQ8dPNUHKaj77AXJXfPpzoseaF3z6WHgY614vppSlSUGH9jVZHJO1V5zqgxGpIbrnCr9aFZXkYqve4bSdmUoIBZDabUMxXJoJT/txU+AUXnIl5+G8Ugl13Q9tYCoir6Tu0Ble3lNMwIYiynbZ6Pin22RrFcxA2C09TXuUP6VrqoOzVqftbG/3lClqCmH9QcKDx/akBzwMTigc0endw6omMMdp2J+tGFuqjyGOZDDfKBCsT3csf1dkYPWjicDsHcKqaZ1bUkhh/Uf8aAQOi+OncHU87FvSJOL4C6hsMssWtVEytBLSZvaIJbjk4fLSxVQgtQaG7vWGgNOtcaqWuFBzeThN6pKS3NA7aA3FCtbVX5v2R8ooH97YNAzV2S1y0DVLnZ1QiTrHXVcF5Y35MKrYufl7VEFMhxfxsNGzNFkyz6S0OhYI+i7ViQbna+hethi4jWriWdvTJGWTRDLppMnNZhlA7J+6Ph5CljD+jLtfQ6jGvIpSfzmHQyjfgdLO6w/F58D0LPjZMWn6MpSt2nK5qosAR25LN4bU1/PIt8b03lA02Cgge97Y+jJOoXUkzJPEJlnmEPLPOIOywDGkZgZYzSwmTGPZlWWj+QYCJIcAbsGPgiv9J0csSs5B0DqjDmtdG7Sy+WVXh4jC4k7k1TAGlhSmfQ6d+uXx0g9JYieAruGzXpb6M6kI6zy5TGCSyowtOduyljr55VUREE0S6rOZ1DtJqlMlbOkoqOf0iUVmT8LgAzGn4O8zVRyYeezO8jQ75viRtXJCcpuFEenh6oCdFQ5UEcvj6Hc3Ia8GMXhGoe0lTk6FhkjZ2wMVzZL9I5oqebm8tZqbrppWb9qTUfL/pUx3nbM4s0UQrTdHaui4Aa6Xtbjfw==7V1bd5s4EP41fkwOIG5+TNKk3V7Otpukm+1LDjYypsXIB+Qk7q9fAcIGhLk4gHAqPyRGSALEzKdvZqTxBFytXt4H1nr5BdnQmyiS/TIB7yaKIsuSTv5FJdukxNCVpMAJXJtW2hfcur8hLZRo6ca1YZiriBHysLvOF86R78M5zpVZQYCe89UWyMtfdW05kCm4nVseW/qva+Pl7rmk/YkP0HWW9NKmRk+srLQyLQiXlo2eM0XgegKuAoRw8m31cgW9aPDScfny1zfz89nPQJLxbPvNuwr0+7uzpLObNk12jxBAHx/d9afr7ffwPnj48RV8evl+8cm5u5qfTZOunyxvQ8fL9df0cfE2HcMAbXwbRv3IE3D5vHQxvF1b8+jsM5EaUrbEK4+eXried4U8FMRtgW2TdpchDtAvmCk2DIMUW57r+KTAgwscNUU+vrFWrhfJ2j9ohjAiN/IF+YievEWbIL7sEmMiP4oGLsgf8sjRn6hCeO4g5HjQWrvh+Ryt4hPzMK56s0i6Jl93nWvKJe3+CQbYJWJzQW8Jo+i5QvKYru98ju/vHZFpcNnwZdCXFnULXzKiSF/Oe4hWEAfkbiR6VgHm+RQkraiumVTynjOCm0rjMiOzBi2zqK44u8738kC+UJFoIR6yzMgCtIl60UMU4CVykG951/vSy7y07Ot8RtGQxoU/IcZbihXWBqO8BMEXFz9kvv9HvkvnGj16F42XlB5s0wOfPO9D9iDTKjrcN4uP0nY8RQ769kWEcNFIrqF/t3T9pPTGjV5TfH+M4kjxZ3cmxTQiWJe2FS53495OTMP0KQ9KAkV9bAUOxBUVVQopkZxUin0APQu7T3mYLpPguCkZKGubqbBGLhn6TM9fo4K9NqlgWtSmVHVumjfRTKmgQ8l97DVq90CvUDKFAWHbwtbrUPgA4BbAebFYjAuF83irleDyyrXtGGZo1bsYVM5An8isMsgsyyw0myXIrPSFzApghAZbMw+eifmb0/xtmtK5lPnIR03l094ERmUEZo68zcoPBdCMBWjMaZUI8ccc7VTY4Ftidccy244JoNKUAKZA0zEBZOiaqVcqi1rQguS+aR8FReiAxCm6IHGnjK0qb2w1eGCrwMmoJyvA6Z34yIc8sVMfBjunUqUygGbYyXRrVFJguag8ybgx3bY19Wsmghqrv6a1NoADQDHLqHl45hD9FsbcWJyxCuBuwrHeerTGLhIm3HhoRomvSNE5c4tUcMdvt+U82IKcdE5EQBqB5eTFZ33uGuumb8Q9WscDZOZCQKqJB7BNBokHpC8pA/PJVwHyIwb5KW+QZ6NIMYm0HEdQyJFQSFDiZRiWQgI2bCQo5PjRBRi80UUVFFJQyFgStLFRSJamKaAfEgmmzKVUuZpEljQZhkRqgkSeHMyrEm+YZ6NYi40/FyRyRCRS1RqSyN5WhQIusaohV4W+WRLRNRkw+ZKB1hO4wU7g05oJnG0yzATOBoXEBD76Cbxkmd+wEzgbIpptMEa+mMJHNIVrTf1AvU3h6smEhMTGjs5nbZVzFKjtrK3FFv6hJRqDzMYqG5PxH+eeO/8lHKdjnpA13o5TVTkVoBWAefglgoaAqXQOmK8TPjbas1nbFoaPUWjwsXx9GenXXYdwMpqFom2IZw5sNK1HsJHZ3YJKiWdGLQGb4tL07t73yURpBKvrHqSaBmbSGPBYQIqNVGRBqsxuFRB1LESBkrWIw0KUzgWiBNy0lLJ627BzFDngf1UObuSv20jA9CVLxEBQpvRjGummm93yHHKpzMfMX6W/IHLdHoWyJjVua9Zr3b5F6n7q17LmE0wSgNA5IGgnCggFF75e6OuPU3swiEPNFGrPaR3ZEeZax6ZK42RCijIQpqQ+wUM7IBsDSuoy7xlNVKNww3W5jarr96TjbDCSWHMBdIhxx2r/yG262WzWxqbL6137hFzNrTzVqIyO8Db4NDZyIkLRnELRWuVeZ/5RaU1hZEUk0RhThE0zKgWId7BNA1xIpQi2derHnjYkhylajMSPrbMTHfVjx4n5hCP7eNgxKuct7mE3nZ22klyMguiMMhdjeSYPwEpMb9swdTYwL5IxjozrVCcM457UQz+ZWP9b4joj2YSpN3XKp0DDNxkjA6Q9J2PU2SUFwo48IWzlnUtD57JIQeBk5zFJfaCYZHU+xZ3wts2naFZ3W/TDDZJPsbAooGXrQs6lfuIO+pvfn/qWoKJjapR6heqxQe4FG9pqmyHlI3OF7Sy19QdZpKOwHo6cb6vsRyeEb6sD35ZckhGiL99WAPHPv+8//rC+O9ub549z4/2HJ/qbYtkX/wdTI5od+uRpUfoSe7cKq3Oua0fSohqVeXWa6ebmKDnc//5fUn3/K4rg+n8=7Vhbb5swFP41eWwFOAR4bJJmndRK21Jp614qLzjgzXAQOE2yX79jMLc46UVNlUorD4n9+Rxfzvn8YTMgk2TzKadZfAMhEwPHCjcDMh04jm1bI/xTyLZCvJFTAVHOQ23UAnP+l2nQ0uiKh6zoGUoAIXnWBxeQpmwhexjNc1j3zZYg+qNmNGIGMF9QYaLfeSjjZl1W23DFeBTroX1XNyS0NtZAEdMQ1h2IXA7IJAeQVSnZTJhQwavjUvnNDrQ2E8tZKp/jQIKvd35x/TPzPrMN94bs6paf6V4eqFjpBfM00/OV2zoIOazSkKl+7AEZr2Mu2TyjC9W6xrQjFstE6OYlF2ICAvLSl4Qh+o0LmcMf1oE9z0OYCh6lCAi2lMoVUjmjCReKLN/gF0jAidxACrpxDqu8HDaWEgnguOQCf3DJ6kcZFOcRQCQYzXhxvoCkbFgUpelsWXWNxaZz1xnr7h9YLjnm/UJPSYJaV4HL5Gl0Xc5viqQkYx0xNGebg6mwmwTjzmCQMJnjwJZ2cIh/HpDKS+8LX7Nk3SFZzZy4wy9PY1TzOmo6b1OPBZ39FzDBMZgQUklfR4UDWd9hyHK5fF9U6Cfd3UOOhIehikhteqvIMj0jR6LH0KCHbZv88PfQw3krehCDHgsQxVmEfPjQixPohUOeKRjBWzFiaDACMskhLT40431ohjM6sWa4BkOq4gc/3gc/ghPzY2QwgYV4+NZVyGUMEaRUXLbouM+V1uYaynAp8DeTcqtvEnQloc8fHF7+6JTvsGydu7o2VeGy6spWV05JHJaGF+oeoyKSsfQ25mmFzriKdjk/Y09Y5dO01DcXp+GSCvTjTMK81Is6lD+vspM0j5h86k1hMjNngkr+0J/H0UnmGSK0yvBky+7V+eV+//kFu8X7Jds5p5yQBC85I/Xkw3WPIx82CQz5cE35GO6Rj+FbyYd/EvlIceo/apFQlY6AqGqrIGXt/5aQJ6XBe6U0aNcvwHHkhq3EUWy12mfUv0ztvtEqpdOddD+h7PRrW/gSdQL9+J7j9folQxy28/j9UapgGKNgaui2Y5Ypg+KRxQXGVrR3P/08wyV43MU7gkf9saLd5NVa2y3fpHyfCmC1/TpWmbffGMnlPw==7VlZV9s8EP01eYTjJc7ymIWE0BZowzldXjiyrdhqFcnIcpb++m9ky7Edh0D61YWewkOQrqSJNPfOaEnLHi03U4Gi8AP3MW1Zhr9p2eOWZZmm0YF/CtlmSLdjZUAgiK87FcCc/MQZ6GgwIT6OK/0k51SSqAp6nDHsyQqGhODrarcFp9UvjVCAa8DcQ7SOfia+DHfLMoqGS0yCUH91z9ENS5R31kAcIp+vS5B90bJHgnOZlZabEabKd7lb7t+Zs843OUBX1961PXv4ONuuzjJjk1OG7JYgMJO/bHpqevxqMb5Fl2t3+vnm/spjth5irBBNtL8miMZYL1hucy+CKSAMKsNQLilgJhQXnMkJWhKqlPGJu1xyGPWBM64b5zwRnrIQSgl0W449gA+YofpQHeLzgPOAYhSR+Nzjy7TBi9Ouk0VmGoo74441zM0TSkeccpHOz/Z9WOUwloL/wCW42+0qOEIeYcEdjwB1HECe6VDt+BUWEm9KctIOnmK+xFLAFA3d2unp2NCx0jb75zoO1oX42trrYUl2OYa03IOd7YJSKGhWT2DYrDF8J5JjBK9DIvEcXKZa1pATXi3pruueQnrWMU8C5h+Uge28uAzsOuM+5Eld5UKGPOAM0YsCHQqeMB/72llFn/c8daoCv2Mptzrno0TyqlbwhsgvpfJXKBvnjq6NlbeMvLItVW6xILBsLDT2kprDzB+obQiMM85whkyI8n46Nx/F4c5HVYlZJ0tMcXJUYAJTJMmqurcdEoseessJfO9OmI5pVIXZsXNh5kYkEgGWelx5O9kz1X7aVJyyUjOV6ne3pl+XdLuW2WDqyRKns5TbqJ7kIHxlVaGIkoBB2YNRSm5DFeQETg8D3bAkvp8FA47JT+SmphTvkVpWulBn2HLGyhboP85CwazlJa2dcgbLoWPibipDtc1ulT2rdyBDmYZTT1FWUynKeUtRzaSovy4r9X9fVqqbajgrdWpZaS6RPCUXUbyQ/04mcqw9xhzrQCZyDpyVGktE3RqFMxYloJbJTSLTwhuXz+OyZx7cVf4kmb0amZHAK1j4PWFEEkTvgRf6dlR4/DKTv+QcPyo4DR0VIjy+Dq9t3O5/o7cPrI9Gpnfg2WLElxE4idWDs3pEeOp2e8Kzwl6Mv+QJYF+KUp1+8ivw+3R+Y9No8jy5F/hWv64Q60DYd5tSSP3ZIxI8gvVs/59AHtFCWTc+XqCEytelkKoWnGPpq/JycmY3uV/sb/111fQa2iusj4Pvs764Wd3Ovl65N97gcjg9kFbU3uAi74d6eU6YJwln9VvK29voc8i2env7SLvOdlNPYgfZtmpst6wOzQO1wnHnIeF5w1m2cQNbhmlFG/iXOs2ghOGzfOKq1SjGQSlIE8LugSL7IlfkLTnSysgv4L/kSsyAkS/lSmmUqhbD0lo+7pVcm2FnYHchYY9cnUtRY6R/By/V1RRrFMgnLeYSpIdQ5GIKHPlY7B31dL8hlxLWBL1P373j3HVP7JHZ3frptPj7ngEeCVuoFr+mZffx4idJ++I/7Vtdk6I4FP01Vu0+aEEQxcfWaWdma6Zqtntrp3tfpiJEYDsSJkRb59dvAgmfaottS7vig5LL5ZKEc869EOwYk8X6I4Wh95U4CHeA5qw7xocOALrVN/iPsGykBQy1xOJS35G2zHDv/0KJ0ZTGpe+gqODHCMHMD4tGmwQBslnBBiklz0W3OcHFk4bQRRXDvQ1x1frdd5gnrbqmZTs+Id/15KktU+5YQOUsDZEHHfKcMxm3HWNCCWHJ1mI9QVhMnpqWx88RxI+b+Waw+v73P59X+M5A3STYtM4h6RAoCtjRoZf9v7oPT38+wWjyR8jWP4drw+yqobGNmi/k8OmTTUKZR1wSQHybWceZ9QshIXfTufFfxNhGXny4ZISbPLbAci9a++wht/3It7WeKVsfBNg01djkGt8Q9ReIIapsAaObh3wjF0k041CjnqWnhk3euxxvTgI2hQsfC687MiO840D7SgIid96TJbXFmDzGOGCBadzwLz7H4ks4RD2XEBcjGPpRzyaLeIcdxa7TeRKab6bBTTCW4VHg3Ah88+ABCVBimfoYy75FIbT9wP2C5uKaDzPLnQSrMB2IDImgSI1mFxx0iQcGqYvYHkcpCgIsOZpJ4H1EhE8y5QPXKMKQ+asiF6GktJv6pYd+Iz4fCNCU/Ixkh6T4mGYxQjIgeVCGfT6vcJNzC4VDtPs0QNt6mumB7pa2133wGm9dK/SFbySDU63cbGemmPk1VAAcoQKULAMHOZLUDWrCe+bwYXqla4epVVNkH56H7H2jzMKekfuAQTFg0u0K9ythL0JCgFlLFeq6D88gIjIlrCBeInWFZhj98IOwIi85GUirKoFwB0ZerCmiAbHvBnzb5nAXHBivEGU+r+xu5I6F7zixFvEYoYi8WLuiiO0ldSNIfkVYRSsnpVtAmO2pRsQoeUITggmN+2cM44/QD87lnN2JPxX1OyhVv6xTiVLqQLbVeak7+41fT35VNPXze9ppVdOC2vogZhOpPLud0OtCxlJoUoR6zpXTCs5erpJWdN6mADko1kdav5F0dVIpf9dJ60SJ+dTpSt09vpN0ZZQSS3qf+MaZRbeuLrWYl1yfXo9wnJjwg0b5/mqa9q+OpoNKBdgBA8wkMMXDOygv+ODnUjw1GxcBnJr5lit+bYKjHy5ncqgCdRIMK4e2sCxLwieEV0gM5sKKSt1ouqocng69mgMZ7IrDuhG1C8ckqiw0edrZpcexGqdanCnxeDtLoOu29NhNj8YgnWpwY5C2KpBObsljxLTQOP0teXIKObLGgGfoTQNPdeDyxDR+WpX0dEYzHW3l9TQrUEdjut84pPXTQTpX3bbJ+wpq22Hj4AWXqsfzZWC3JLkCkoDmixajgqZ2XeEK1hWkNr6bdQWzlD2s86xYqzf2LuCBZfXVgdLRg9KU7XhX4FQPPvX9K5IIz8jz24iGvk80DtSMMz/WP/i1E+V4OsK9Lj0cswrVrjv9T9+UHB4IYus8ScMEJQE8z1p0SaVf9De0WkmgrjuolTPAqGdpo+xjFtNuuc586xwyaOWllZfaSXJwHn1Ji9Dz6st7ehG7X0dcRv292lJ+VeittcVqteVqtOXlulo/k2YMR72RtY3P1yQbVh3ZsCozppen7Gip4M3sv2qJe/aPP+P2Pw==7ZZLj9sgEMc/jY+J/JCdvW4e3pW6e9hEartHAthGixkvJk2cT18w+JWs1KZSW1VqLob/DDPA/MaxF63K04NEVfEMhHIv9MnJi9ZeGAaBn+iHURqrLJLQCrlkxDkNwo6dqRVjJx4YofXETwFwxaqpiEEIitVEQ1LCceqWAZ8mrVBOr4QdRvxa/cKIKvpj+YPhkbK8cKnvYmcoUefshLpABI4jKdp40UoCKDsqTyvKzd111/LSvNMoxK9ihz4f10SUZ7ya2WDpLUv6I0gq1C+HTt/wK97uVp/Cr+n66fFlk+2pW+J/Q/zg7ssLE66TLDPQufR9I2wNyfvBHHS5hT0o0JZnEDDIBhCk0Mwsm9UST9YUSplq35vNhalxqec5QM4pqlg9x1BqGdfaJc1QybghrcuzvMyT5OZpd+z2uped3CkuzSC3BVRNR0WhSq5Hgbb1ZfX1hKC6oMRNEGe50GOsr51Kk5RKxTRa985QMkJMRBOjMpHLU26aaG7BDe3ThG26+KatAjMSoHDRTWol4Y2ugINs9xct2p8pA+N8pJP2p3UJB0HanbZnqBBmIn+imeFjMShbB/aiv4QfUuRoM2elp1EPOaoeKJRUyUa7OOvCQeTeD3Hi5sdRu3XdVow6LXIach2e95EHivXAgXwD1ME/CzWjR8j+U30L1TZJ92oP/xznif+3OY+uOMd2lWp03S7h0Ke02PaM/DwJktbsjPZtKFPZCpjG3cSNl168NrEOCmr73x9clV6AoBdV7ySNc3rZHbYLf2Mh4+kLK4qvCnn3QR3D2+uop8P3QWsbfWRFm+8= -------------------------------------------------------------------------------- /penguins.csv: -------------------------------------------------------------------------------- 1 | species,island,bill_length_mm,bill_depth_mm,flipper_length_mm,body_mass_g,sex,year 2 | Adelie,Torgersen,39.1,18.7,181,3750,male,2007 3 | Adelie,Torgersen,39.5,17.4,186,3800,female,2007 4 | Adelie,Torgersen,40.3,18,195,3250,female,2007 5 | Adelie,Torgersen,NA,NA,NA,NA,NA,2007 6 | Adelie,Torgersen,36.7,19.3,193,3450,female,2007 7 | Adelie,Torgersen,39.3,20.6,190,3650,male,2007 8 | Adelie,Torgersen,38.9,17.8,181,3625,female,2007 9 | Adelie,Torgersen,39.2,19.6,195,4675,male,2007 10 | Adelie,Torgersen,34.1,18.1,193,3475,NA,2007 11 | Adelie,Torgersen,42,20.2,190,4250,NA,2007 12 | Adelie,Torgersen,37.8,17.1,186,3300,NA,2007 13 | Adelie,Torgersen,37.8,17.3,180,3700,NA,2007 14 | Adelie,Torgersen,41.1,17.6,182,3200,female,2007 15 | Adelie,Torgersen,38.6,21.2,191,3800,male,2007 16 | Adelie,Torgersen,34.6,21.1,198,4400,male,2007 17 | Adelie,Torgersen,36.6,17.8,185,3700,female,2007 18 | Adelie,Torgersen,38.7,19,195,3450,female,2007 19 | Adelie,Torgersen,42.5,20.7,197,4500,male,2007 20 | Adelie,Torgersen,34.4,18.4,184,3325,female,2007 21 | Adelie,Torgersen,46,21.5,194,4200,male,2007 22 | Adelie,Biscoe,37.8,18.3,174,3400,female,2007 23 | Adelie,Biscoe,37.7,18.7,180,3600,male,2007 24 | Adelie,Biscoe,35.9,19.2,189,3800,female,2007 25 | Adelie,Biscoe,38.2,18.1,185,3950,male,2007 26 | Adelie,Biscoe,38.8,17.2,180,3800,male,2007 27 | Adelie,Biscoe,35.3,18.9,187,3800,female,2007 28 | Adelie,Biscoe,40.6,18.6,183,3550,male,2007 29 | Adelie,Biscoe,40.5,17.9,187,3200,female,2007 30 | Adelie,Biscoe,37.9,18.6,172,3150,female,2007 31 | Adelie,Biscoe,40.5,18.9,180,3950,male,2007 32 | Adelie,Dream,39.5,16.7,178,3250,female,2007 33 | Adelie,Dream,37.2,18.1,178,3900,male,2007 34 | Adelie,Dream,39.5,17.8,188,3300,female,2007 35 | Adelie,Dream,40.9,18.9,184,3900,male,2007 36 | Adelie,Dream,36.4,17,195,3325,female,2007 37 | Adelie,Dream,39.2,21.1,196,4150,male,2007 38 | Adelie,Dream,38.8,20,190,3950,male,2007 39 | Adelie,Dream,42.2,18.5,180,3550,female,2007 40 | Adelie,Dream,37.6,19.3,181,3300,female,2007 41 | Adelie,Dream,39.8,19.1,184,4650,male,2007 42 | Adelie,Dream,36.5,18,182,3150,female,2007 43 | Adelie,Dream,40.8,18.4,195,3900,male,2007 44 | Adelie,Dream,36,18.5,186,3100,female,2007 45 | Adelie,Dream,44.1,19.7,196,4400,male,2007 46 | Adelie,Dream,37,16.9,185,3000,female,2007 47 | Adelie,Dream,39.6,18.8,190,4600,male,2007 48 | Adelie,Dream,41.1,19,182,3425,male,2007 49 | Adelie,Dream,37.5,18.9,179,2975,NA,2007 50 | Adelie,Dream,36,17.9,190,3450,female,2007 51 | Adelie,Dream,42.3,21.2,191,4150,male,2007 52 | Adelie,Biscoe,39.6,17.7,186,3500,female,2008 53 | Adelie,Biscoe,40.1,18.9,188,4300,male,2008 54 | Adelie,Biscoe,35,17.9,190,3450,female,2008 55 | Adelie,Biscoe,42,19.5,200,4050,male,2008 56 | Adelie,Biscoe,34.5,18.1,187,2900,female,2008 57 | Adelie,Biscoe,41.4,18.6,191,3700,male,2008 58 | Adelie,Biscoe,39,17.5,186,3550,female,2008 59 | Adelie,Biscoe,40.6,18.8,193,3800,male,2008 60 | Adelie,Biscoe,36.5,16.6,181,2850,female,2008 61 | Adelie,Biscoe,37.6,19.1,194,3750,male,2008 62 | Adelie,Biscoe,35.7,16.9,185,3150,female,2008 63 | Adelie,Biscoe,41.3,21.1,195,4400,male,2008 64 | Adelie,Biscoe,37.6,17,185,3600,female,2008 65 | Adelie,Biscoe,41.1,18.2,192,4050,male,2008 66 | Adelie,Biscoe,36.4,17.1,184,2850,female,2008 67 | Adelie,Biscoe,41.6,18,192,3950,male,2008 68 | Adelie,Biscoe,35.5,16.2,195,3350,female,2008 69 | Adelie,Biscoe,41.1,19.1,188,4100,male,2008 70 | Adelie,Torgersen,35.9,16.6,190,3050,female,2008 71 | Adelie,Torgersen,41.8,19.4,198,4450,male,2008 72 | Adelie,Torgersen,33.5,19,190,3600,female,2008 73 | Adelie,Torgersen,39.7,18.4,190,3900,male,2008 74 | Adelie,Torgersen,39.6,17.2,196,3550,female,2008 75 | Adelie,Torgersen,45.8,18.9,197,4150,male,2008 76 | Adelie,Torgersen,35.5,17.5,190,3700,female,2008 77 | Adelie,Torgersen,42.8,18.5,195,4250,male,2008 78 | Adelie,Torgersen,40.9,16.8,191,3700,female,2008 79 | Adelie,Torgersen,37.2,19.4,184,3900,male,2008 80 | Adelie,Torgersen,36.2,16.1,187,3550,female,2008 81 | Adelie,Torgersen,42.1,19.1,195,4000,male,2008 82 | Adelie,Torgersen,34.6,17.2,189,3200,female,2008 83 | Adelie,Torgersen,42.9,17.6,196,4700,male,2008 84 | Adelie,Torgersen,36.7,18.8,187,3800,female,2008 85 | Adelie,Torgersen,35.1,19.4,193,4200,male,2008 86 | Adelie,Dream,37.3,17.8,191,3350,female,2008 87 | Adelie,Dream,41.3,20.3,194,3550,male,2008 88 | Adelie,Dream,36.3,19.5,190,3800,male,2008 89 | Adelie,Dream,36.9,18.6,189,3500,female,2008 90 | Adelie,Dream,38.3,19.2,189,3950,male,2008 91 | Adelie,Dream,38.9,18.8,190,3600,female,2008 92 | Adelie,Dream,35.7,18,202,3550,female,2008 93 | Adelie,Dream,41.1,18.1,205,4300,male,2008 94 | Adelie,Dream,34,17.1,185,3400,female,2008 95 | Adelie,Dream,39.6,18.1,186,4450,male,2008 96 | Adelie,Dream,36.2,17.3,187,3300,female,2008 97 | Adelie,Dream,40.8,18.9,208,4300,male,2008 98 | Adelie,Dream,38.1,18.6,190,3700,female,2008 99 | Adelie,Dream,40.3,18.5,196,4350,male,2008 100 | Adelie,Dream,33.1,16.1,178,2900,female,2008 101 | Adelie,Dream,43.2,18.5,192,4100,male,2008 102 | Adelie,Biscoe,35,17.9,192,3725,female,2009 103 | Adelie,Biscoe,41,20,203,4725,male,2009 104 | Adelie,Biscoe,37.7,16,183,3075,female,2009 105 | Adelie,Biscoe,37.8,20,190,4250,male,2009 106 | Adelie,Biscoe,37.9,18.6,193,2925,female,2009 107 | Adelie,Biscoe,39.7,18.9,184,3550,male,2009 108 | Adelie,Biscoe,38.6,17.2,199,3750,female,2009 109 | Adelie,Biscoe,38.2,20,190,3900,male,2009 110 | Adelie,Biscoe,38.1,17,181,3175,female,2009 111 | Adelie,Biscoe,43.2,19,197,4775,male,2009 112 | Adelie,Biscoe,38.1,16.5,198,3825,female,2009 113 | Adelie,Biscoe,45.6,20.3,191,4600,male,2009 114 | Adelie,Biscoe,39.7,17.7,193,3200,female,2009 115 | Adelie,Biscoe,42.2,19.5,197,4275,male,2009 116 | Adelie,Biscoe,39.6,20.7,191,3900,female,2009 117 | Adelie,Biscoe,42.7,18.3,196,4075,male,2009 118 | Adelie,Torgersen,38.6,17,188,2900,female,2009 119 | Adelie,Torgersen,37.3,20.5,199,3775,male,2009 120 | Adelie,Torgersen,35.7,17,189,3350,female,2009 121 | Adelie,Torgersen,41.1,18.6,189,3325,male,2009 122 | Adelie,Torgersen,36.2,17.2,187,3150,female,2009 123 | Adelie,Torgersen,37.7,19.8,198,3500,male,2009 124 | Adelie,Torgersen,40.2,17,176,3450,female,2009 125 | Adelie,Torgersen,41.4,18.5,202,3875,male,2009 126 | Adelie,Torgersen,35.2,15.9,186,3050,female,2009 127 | Adelie,Torgersen,40.6,19,199,4000,male,2009 128 | Adelie,Torgersen,38.8,17.6,191,3275,female,2009 129 | Adelie,Torgersen,41.5,18.3,195,4300,male,2009 130 | Adelie,Torgersen,39,17.1,191,3050,female,2009 131 | Adelie,Torgersen,44.1,18,210,4000,male,2009 132 | Adelie,Torgersen,38.5,17.9,190,3325,female,2009 133 | Adelie,Torgersen,43.1,19.2,197,3500,male,2009 134 | Adelie,Dream,36.8,18.5,193,3500,female,2009 135 | Adelie,Dream,37.5,18.5,199,4475,male,2009 136 | Adelie,Dream,38.1,17.6,187,3425,female,2009 137 | Adelie,Dream,41.1,17.5,190,3900,male,2009 138 | Adelie,Dream,35.6,17.5,191,3175,female,2009 139 | Adelie,Dream,40.2,20.1,200,3975,male,2009 140 | Adelie,Dream,37,16.5,185,3400,female,2009 141 | Adelie,Dream,39.7,17.9,193,4250,male,2009 142 | Adelie,Dream,40.2,17.1,193,3400,female,2009 143 | Adelie,Dream,40.6,17.2,187,3475,male,2009 144 | Adelie,Dream,32.1,15.5,188,3050,female,2009 145 | Adelie,Dream,40.7,17,190,3725,male,2009 146 | Adelie,Dream,37.3,16.8,192,3000,female,2009 147 | Adelie,Dream,39,18.7,185,3650,male,2009 148 | Adelie,Dream,39.2,18.6,190,4250,male,2009 149 | Adelie,Dream,36.6,18.4,184,3475,female,2009 150 | Adelie,Dream,36,17.8,195,3450,female,2009 151 | Adelie,Dream,37.8,18.1,193,3750,male,2009 152 | Adelie,Dream,36,17.1,187,3700,female,2009 153 | Adelie,Dream,41.5,18.5,201,4000,male,2009 154 | Gentoo,Biscoe,46.1,13.2,211,4500,female,2007 155 | Gentoo,Biscoe,50,16.3,230,5700,male,2007 156 | Gentoo,Biscoe,48.7,14.1,210,4450,female,2007 157 | Gentoo,Biscoe,50,15.2,218,5700,male,2007 158 | Gentoo,Biscoe,47.6,14.5,215,5400,male,2007 159 | Gentoo,Biscoe,46.5,13.5,210,4550,female,2007 160 | Gentoo,Biscoe,45.4,14.6,211,4800,female,2007 161 | Gentoo,Biscoe,46.7,15.3,219,5200,male,2007 162 | Gentoo,Biscoe,43.3,13.4,209,4400,female,2007 163 | Gentoo,Biscoe,46.8,15.4,215,5150,male,2007 164 | Gentoo,Biscoe,40.9,13.7,214,4650,female,2007 165 | Gentoo,Biscoe,49,16.1,216,5550,male,2007 166 | Gentoo,Biscoe,45.5,13.7,214,4650,female,2007 167 | Gentoo,Biscoe,48.4,14.6,213,5850,male,2007 168 | Gentoo,Biscoe,45.8,14.6,210,4200,female,2007 169 | Gentoo,Biscoe,49.3,15.7,217,5850,male,2007 170 | Gentoo,Biscoe,42,13.5,210,4150,female,2007 171 | Gentoo,Biscoe,49.2,15.2,221,6300,male,2007 172 | Gentoo,Biscoe,46.2,14.5,209,4800,female,2007 173 | Gentoo,Biscoe,48.7,15.1,222,5350,male,2007 174 | Gentoo,Biscoe,50.2,14.3,218,5700,male,2007 175 | Gentoo,Biscoe,45.1,14.5,215,5000,female,2007 176 | Gentoo,Biscoe,46.5,14.5,213,4400,female,2007 177 | Gentoo,Biscoe,46.3,15.8,215,5050,male,2007 178 | Gentoo,Biscoe,42.9,13.1,215,5000,female,2007 179 | Gentoo,Biscoe,46.1,15.1,215,5100,male,2007 180 | Gentoo,Biscoe,44.5,14.3,216,4100,NA,2007 181 | Gentoo,Biscoe,47.8,15,215,5650,male,2007 182 | Gentoo,Biscoe,48.2,14.3,210,4600,female,2007 183 | Gentoo,Biscoe,50,15.3,220,5550,male,2007 184 | Gentoo,Biscoe,47.3,15.3,222,5250,male,2007 185 | Gentoo,Biscoe,42.8,14.2,209,4700,female,2007 186 | Gentoo,Biscoe,45.1,14.5,207,5050,female,2007 187 | Gentoo,Biscoe,59.6,17,230,6050,male,2007 188 | Gentoo,Biscoe,49.1,14.8,220,5150,female,2008 189 | Gentoo,Biscoe,48.4,16.3,220,5400,male,2008 190 | Gentoo,Biscoe,42.6,13.7,213,4950,female,2008 191 | Gentoo,Biscoe,44.4,17.3,219,5250,male,2008 192 | Gentoo,Biscoe,44,13.6,208,4350,female,2008 193 | Gentoo,Biscoe,48.7,15.7,208,5350,male,2008 194 | Gentoo,Biscoe,42.7,13.7,208,3950,female,2008 195 | Gentoo,Biscoe,49.6,16,225,5700,male,2008 196 | Gentoo,Biscoe,45.3,13.7,210,4300,female,2008 197 | Gentoo,Biscoe,49.6,15,216,4750,male,2008 198 | Gentoo,Biscoe,50.5,15.9,222,5550,male,2008 199 | Gentoo,Biscoe,43.6,13.9,217,4900,female,2008 200 | Gentoo,Biscoe,45.5,13.9,210,4200,female,2008 201 | Gentoo,Biscoe,50.5,15.9,225,5400,male,2008 202 | Gentoo,Biscoe,44.9,13.3,213,5100,female,2008 203 | Gentoo,Biscoe,45.2,15.8,215,5300,male,2008 204 | Gentoo,Biscoe,46.6,14.2,210,4850,female,2008 205 | Gentoo,Biscoe,48.5,14.1,220,5300,male,2008 206 | Gentoo,Biscoe,45.1,14.4,210,4400,female,2008 207 | Gentoo,Biscoe,50.1,15,225,5000,male,2008 208 | Gentoo,Biscoe,46.5,14.4,217,4900,female,2008 209 | Gentoo,Biscoe,45,15.4,220,5050,male,2008 210 | Gentoo,Biscoe,43.8,13.9,208,4300,female,2008 211 | Gentoo,Biscoe,45.5,15,220,5000,male,2008 212 | Gentoo,Biscoe,43.2,14.5,208,4450,female,2008 213 | Gentoo,Biscoe,50.4,15.3,224,5550,male,2008 214 | Gentoo,Biscoe,45.3,13.8,208,4200,female,2008 215 | Gentoo,Biscoe,46.2,14.9,221,5300,male,2008 216 | Gentoo,Biscoe,45.7,13.9,214,4400,female,2008 217 | Gentoo,Biscoe,54.3,15.7,231,5650,male,2008 218 | Gentoo,Biscoe,45.8,14.2,219,4700,female,2008 219 | Gentoo,Biscoe,49.8,16.8,230,5700,male,2008 220 | Gentoo,Biscoe,46.2,14.4,214,4650,NA,2008 221 | Gentoo,Biscoe,49.5,16.2,229,5800,male,2008 222 | Gentoo,Biscoe,43.5,14.2,220,4700,female,2008 223 | Gentoo,Biscoe,50.7,15,223,5550,male,2008 224 | Gentoo,Biscoe,47.7,15,216,4750,female,2008 225 | Gentoo,Biscoe,46.4,15.6,221,5000,male,2008 226 | Gentoo,Biscoe,48.2,15.6,221,5100,male,2008 227 | Gentoo,Biscoe,46.5,14.8,217,5200,female,2008 228 | Gentoo,Biscoe,46.4,15,216,4700,female,2008 229 | Gentoo,Biscoe,48.6,16,230,5800,male,2008 230 | Gentoo,Biscoe,47.5,14.2,209,4600,female,2008 231 | Gentoo,Biscoe,51.1,16.3,220,6000,male,2008 232 | Gentoo,Biscoe,45.2,13.8,215,4750,female,2008 233 | Gentoo,Biscoe,45.2,16.4,223,5950,male,2008 234 | Gentoo,Biscoe,49.1,14.5,212,4625,female,2009 235 | Gentoo,Biscoe,52.5,15.6,221,5450,male,2009 236 | Gentoo,Biscoe,47.4,14.6,212,4725,female,2009 237 | Gentoo,Biscoe,50,15.9,224,5350,male,2009 238 | Gentoo,Biscoe,44.9,13.8,212,4750,female,2009 239 | Gentoo,Biscoe,50.8,17.3,228,5600,male,2009 240 | Gentoo,Biscoe,43.4,14.4,218,4600,female,2009 241 | Gentoo,Biscoe,51.3,14.2,218,5300,male,2009 242 | Gentoo,Biscoe,47.5,14,212,4875,female,2009 243 | Gentoo,Biscoe,52.1,17,230,5550,male,2009 244 | Gentoo,Biscoe,47.5,15,218,4950,female,2009 245 | Gentoo,Biscoe,52.2,17.1,228,5400,male,2009 246 | Gentoo,Biscoe,45.5,14.5,212,4750,female,2009 247 | Gentoo,Biscoe,49.5,16.1,224,5650,male,2009 248 | Gentoo,Biscoe,44.5,14.7,214,4850,female,2009 249 | Gentoo,Biscoe,50.8,15.7,226,5200,male,2009 250 | Gentoo,Biscoe,49.4,15.8,216,4925,male,2009 251 | Gentoo,Biscoe,46.9,14.6,222,4875,female,2009 252 | Gentoo,Biscoe,48.4,14.4,203,4625,female,2009 253 | Gentoo,Biscoe,51.1,16.5,225,5250,male,2009 254 | Gentoo,Biscoe,48.5,15,219,4850,female,2009 255 | Gentoo,Biscoe,55.9,17,228,5600,male,2009 256 | Gentoo,Biscoe,47.2,15.5,215,4975,female,2009 257 | Gentoo,Biscoe,49.1,15,228,5500,male,2009 258 | Gentoo,Biscoe,47.3,13.8,216,4725,NA,2009 259 | Gentoo,Biscoe,46.8,16.1,215,5500,male,2009 260 | Gentoo,Biscoe,41.7,14.7,210,4700,female,2009 261 | Gentoo,Biscoe,53.4,15.8,219,5500,male,2009 262 | Gentoo,Biscoe,43.3,14,208,4575,female,2009 263 | Gentoo,Biscoe,48.1,15.1,209,5500,male,2009 264 | Gentoo,Biscoe,50.5,15.2,216,5000,female,2009 265 | Gentoo,Biscoe,49.8,15.9,229,5950,male,2009 266 | Gentoo,Biscoe,43.5,15.2,213,4650,female,2009 267 | Gentoo,Biscoe,51.5,16.3,230,5500,male,2009 268 | Gentoo,Biscoe,46.2,14.1,217,4375,female,2009 269 | Gentoo,Biscoe,55.1,16,230,5850,male,2009 270 | Gentoo,Biscoe,44.5,15.7,217,4875,NA,2009 271 | Gentoo,Biscoe,48.8,16.2,222,6000,male,2009 272 | Gentoo,Biscoe,47.2,13.7,214,4925,female,2009 273 | Gentoo,Biscoe,NA,NA,NA,NA,NA,2009 274 | Gentoo,Biscoe,46.8,14.3,215,4850,female,2009 275 | Gentoo,Biscoe,50.4,15.7,222,5750,male,2009 276 | Gentoo,Biscoe,45.2,14.8,212,5200,female,2009 277 | Gentoo,Biscoe,49.9,16.1,213,5400,male,2009 278 | Chinstrap,Dream,46.5,17.9,192,3500,female,2007 279 | Chinstrap,Dream,50,19.5,196,3900,male,2007 280 | Chinstrap,Dream,51.3,19.2,193,3650,male,2007 281 | Chinstrap,Dream,45.4,18.7,188,3525,female,2007 282 | Chinstrap,Dream,52.7,19.8,197,3725,male,2007 283 | Chinstrap,Dream,45.2,17.8,198,3950,female,2007 284 | Chinstrap,Dream,46.1,18.2,178,3250,female,2007 285 | Chinstrap,Dream,51.3,18.2,197,3750,male,2007 286 | Chinstrap,Dream,46,18.9,195,4150,female,2007 287 | Chinstrap,Dream,51.3,19.9,198,3700,male,2007 288 | Chinstrap,Dream,46.6,17.8,193,3800,female,2007 289 | Chinstrap,Dream,51.7,20.3,194,3775,male,2007 290 | Chinstrap,Dream,47,17.3,185,3700,female,2007 291 | Chinstrap,Dream,52,18.1,201,4050,male,2007 292 | Chinstrap,Dream,45.9,17.1,190,3575,female,2007 293 | Chinstrap,Dream,50.5,19.6,201,4050,male,2007 294 | Chinstrap,Dream,50.3,20,197,3300,male,2007 295 | Chinstrap,Dream,58,17.8,181,3700,female,2007 296 | Chinstrap,Dream,46.4,18.6,190,3450,female,2007 297 | Chinstrap,Dream,49.2,18.2,195,4400,male,2007 298 | Chinstrap,Dream,42.4,17.3,181,3600,female,2007 299 | Chinstrap,Dream,48.5,17.5,191,3400,male,2007 300 | Chinstrap,Dream,43.2,16.6,187,2900,female,2007 301 | Chinstrap,Dream,50.6,19.4,193,3800,male,2007 302 | Chinstrap,Dream,46.7,17.9,195,3300,female,2007 303 | Chinstrap,Dream,52,19,197,4150,male,2007 304 | Chinstrap,Dream,50.5,18.4,200,3400,female,2008 305 | Chinstrap,Dream,49.5,19,200,3800,male,2008 306 | Chinstrap,Dream,46.4,17.8,191,3700,female,2008 307 | Chinstrap,Dream,52.8,20,205,4550,male,2008 308 | Chinstrap,Dream,40.9,16.6,187,3200,female,2008 309 | Chinstrap,Dream,54.2,20.8,201,4300,male,2008 310 | Chinstrap,Dream,42.5,16.7,187,3350,female,2008 311 | Chinstrap,Dream,51,18.8,203,4100,male,2008 312 | Chinstrap,Dream,49.7,18.6,195,3600,male,2008 313 | Chinstrap,Dream,47.5,16.8,199,3900,female,2008 314 | Chinstrap,Dream,47.6,18.3,195,3850,female,2008 315 | Chinstrap,Dream,52,20.7,210,4800,male,2008 316 | Chinstrap,Dream,46.9,16.6,192,2700,female,2008 317 | Chinstrap,Dream,53.5,19.9,205,4500,male,2008 318 | Chinstrap,Dream,49,19.5,210,3950,male,2008 319 | Chinstrap,Dream,46.2,17.5,187,3650,female,2008 320 | Chinstrap,Dream,50.9,19.1,196,3550,male,2008 321 | Chinstrap,Dream,45.5,17,196,3500,female,2008 322 | Chinstrap,Dream,50.9,17.9,196,3675,female,2009 323 | Chinstrap,Dream,50.8,18.5,201,4450,male,2009 324 | Chinstrap,Dream,50.1,17.9,190,3400,female,2009 325 | Chinstrap,Dream,49,19.6,212,4300,male,2009 326 | Chinstrap,Dream,51.5,18.7,187,3250,male,2009 327 | Chinstrap,Dream,49.8,17.3,198,3675,female,2009 328 | Chinstrap,Dream,48.1,16.4,199,3325,female,2009 329 | Chinstrap,Dream,51.4,19,201,3950,male,2009 330 | Chinstrap,Dream,45.7,17.3,193,3600,female,2009 331 | Chinstrap,Dream,50.7,19.7,203,4050,male,2009 332 | Chinstrap,Dream,42.5,17.3,187,3350,female,2009 333 | Chinstrap,Dream,52.2,18.8,197,3450,male,2009 334 | Chinstrap,Dream,45.2,16.6,191,3250,female,2009 335 | Chinstrap,Dream,49.3,19.9,203,4050,male,2009 336 | Chinstrap,Dream,50.2,18.8,202,3800,male,2009 337 | Chinstrap,Dream,45.6,19.4,194,3525,female,2009 338 | Chinstrap,Dream,51.9,19.5,206,3950,male,2009 339 | Chinstrap,Dream,46.8,16.5,189,3650,female,2009 340 | Chinstrap,Dream,45.7,17,195,3650,female,2009 341 | Chinstrap,Dream,55.8,19.8,207,4000,male,2009 342 | Chinstrap,Dream,43.5,18.1,202,3400,female,2009 343 | Chinstrap,Dream,49.6,18.2,193,3775,male,2009 344 | Chinstrap,Dream,50.8,19,210,4100,male,2009 345 | Chinstrap,Dream,50.2,18.7,198,3775,female,2009 346 | -------------------------------------------------------------------------------- /observable.qmd: -------------------------------------------------------------------------------- 1 | # Observable 2 | 3 | Compared with Shiny and Dash, Observable seems like another world: 4 | 5 | - It is generally used as a hosted service. 6 | 7 | - Virtually everything runs in the user's browser. 8 | 9 | - Reactivity is baked in to everything. 10 | 11 | - It uses (a very close approximation to) JavaScript. 12 | 13 | That said, there's a few things from your R and tidyverse world that may help you get acquainted: 14 | 15 | - Functional-programming ideas translate well from R to JavaScript. 16 | 17 | - There are a couple of "dplyr/tidyr"-like packages in JavaScript: [arquero](https://uwdata.github.io/arquero/) and [tidyjs](https://pbeshai.github.io/tidy/). 18 | 19 | - There are a couple of JavaScript visualization packages that use grammar-of-graphics: [Vega-Lite](https://vega.github.io/vega-lite/) and [Observable Plot](https://observablehq.com/@observablehq/plot). 20 | 21 | When I find myself overwhelmed, I try to remember that the point is, largely, to "do stuff to data frames". 22 | Knowing how to "do stuff" and "think about stuff" using tidyverse makes it easier for me to figure out the same "stuff" elsewhere. 23 | 24 | ## Principles 25 | 26 | ### Hosted service runs in browser 27 | 28 | The best-known use for Observable is at the site for which is is named: [Observable](https://observablehq.com/). 29 | 30 | Like many hosted services, the Observable website is free to use if everything you are doing is open, i.e. the GitHub model. 31 | 32 | The Observable service uses the [Observable runtime](https://github.com/observablehq/runtime) and the [Observable standard-library](https://github.com/observablehq/stdlib); these are also available in the [Quarto](https://quarto.org/) platform developed by RStudio, which is used for this book. 33 | 34 | ### Reactivity baked in 35 | 36 | Observable is like a "traditional" notebook (e.g. RMarkdown or Jupyter), crossed with Excel, and powered by JavaScript. 37 | How hard can that be? 38 | 😅 39 | 40 | Following one of Mike Bostock's [examples](https://observablehq.com/@observablehq/introduction-to-generators#cell-844), let's define a variable where the value is updated every second. 41 | Don't worry about the JS syntax just yet; for now, let's just trust that it works: 42 | 43 | ```{ojs} 44 | //| output: all 45 | a = { 46 | let i = 0; 47 | while (true) { 48 | await Promises.delay(1000); 49 | yield ++i; 50 | } 51 | } 52 | ``` 53 | 54 | The code runs in your browser, you should see the value of `a` changing. 55 | 56 | If we define another variable and set it equal to `a`, its value updates *with* `a`: 57 | 58 | ```{ojs} 59 | //| output: all 60 | b = a 61 | ``` 62 | 63 | Furthermore, the order of execution depends on the reactive dependencies, not the order of appearance: 64 | 65 | ```{ojs} 66 | c 67 | ``` 68 | 69 | ```{ojs} 70 | //| output: all 71 | c = a + 10 72 | ``` 73 | 74 | This ordering (or lack thereof) gives us great freedom, but also freedom to confuse ourselves. 75 | Often times, I put the headline result right at the top of an Observable notebook, then work backwards to show the supporting work. 76 | 77 | ### JavaScript 78 | 79 | Although Observable cells can use a variety of languages, the core language is JavaScript. 80 | Or at least a close approximation to JavaScript. 81 | 82 | Coming from R, these are the biggest things I need to keep in mind: 83 | 84 | - Objects (analgous to R's named lists) and arrays (analgous to R's unnamed lists and vectors) are mutable. 85 | If you pass an object as an argument to a function, then change the object in the function, the original object is changed. 86 | This differs from R, and can lead to nasty surprises. 87 | 88 | - Strings and numbers are immutable. 89 | Also, a scalar value is different from an array containing a single scalar value. 90 | 91 | ### Tidyverse thinking helps 92 | 93 | It *does* take a while to get used to JavaScript. 94 | That said, it is more-and-more becoming a language for data-science alongside R and Python. 95 | 96 | Personally, I rely on the mental models I have developed using dplyr, purrr, tidyr, and ggplot2. 97 | When working in JavaScript, there may or may not be an analogue to the tidyverse function you have in mind. 98 | The JavaScript function may take arguments in a different order, or have a completely different way of working. 99 | For me, it helps to know "what I want to do with the data". 100 | It also helps to have the confidence of having done something similar using tidyverse. 101 | 102 | ### `viewof` is a useful construct 103 | 104 | This is something particular to Observable, not JavaScript in general. 105 | Once I started to get comfortable with `viewof`, Observable got easier for me. 106 | 107 | Consider an Observable input: 108 | 109 | ```{ojs} 110 | viewof clicks = Inputs.button("OK", {label: "Click me"}) 111 | ``` 112 | 113 | ```{ojs} 114 | clicks 115 | ``` 116 | 117 | In this context, the variable `clicks`: 118 | 119 | - has a *value*: number of times the button has been clicked. 120 | - has a *view*: the rendered view in the browser. 121 | 122 | When we use `viewof clicks = ...`, we are telling Observable: 123 | 124 | - we want to view the button **here** 125 | - we want to access value of the button using the variable `clicks` 126 | 127 | Thus, we can use the variable `clicks` elsewhere in the notebook. 128 | The *view* is a side-effect; the *value* is, well, a value. 129 | 130 | ## Demonstration app 131 | 132 | Here's the link to the now-familar [aggregator app](https://observablehq.com/@ijlyttle/aggregate-local). 133 | 134 | In Observable, there is not a clear distiction between an input and an output. 135 | I find it helpful to think of *everything* in Observable as a reactive variable. 136 | 137 | ![Reactivity diagram for Observable demo-app](images/observable-aggregate-local.svg){.filter} 138 | 139 | As noted above, and as we'll see in greater detail, we use the `viewof` interface often to display things to the screen, while keeping track of the value. 140 | This is such an important concept that I indicate which of the variables in the app use the `viewof` interface. 141 | 142 | ![Legend: Reactivity diagram for Observable demo-app](images/observable-legend.svg){.filter} 143 | 144 | Observable does not require variables to be defined in any particular order. 145 | As a result, I have adapted a style (I've see others do it, too) where a notebook has three sections: 146 | 147 | - Showcase: mostly graphical and/or interactive, aimed at a general audience. 148 | - Workshop: contains supporting code and explanations, aimed at a more-technical audience. 149 | - Appendix: import objects and offer functions for other notebooks to import. 150 | 151 | In this chapter, we'll go over this "backwards". 152 | 153 | ### Appendix 154 | 155 | Here's where we import stuff into our notebook. 156 | 157 | // import { aq } from "@uwdata/arquero" 158 | 159 | This is how you would import from another notebook. 160 | Arquero is already a part of the Observable standard library, so we'll use it as it is rather than import it. 161 | Here, we're importing objects from another notebook, in this case, a notebook that features the arquero library. 162 | 163 | Arquero contains functionality along the lines of dplyr and tidyr. 164 | 165 | Also tidyjs does much the same thing - it's a matter of preference which you use. 166 | Tidyjs is designed to be familiar to tidyverse users. 167 | 168 | I use a lot of Vega-Lite; arquero is made by the same group. 169 | Also, [arquero is designed to work with Apache Arrow](https://observablehq.com/@uwdata/arquero-and-apache-arrow). 170 | 171 | ### Workshop 172 | 173 | Our first step is to import our data into the notebook. 174 | One way to do that is to use a file attachment, one of the few times we interact with Observable not using a cell. 175 | 176 | If we have the result of a multi-step process that we want to put into a variable, we can make put the code in some curly braces, then `return` the result: 177 | 178 | ```{ojs} 179 | //| output: all 180 | inp = { 181 | const text = await FileAttachment("penguins.csv").text(); 182 | const textRemoveNA = text.replace(/,NA/gi, ","); 183 | 184 | return aq.fromCSV(textRemoveNA); 185 | } 186 | ``` 187 | 188 | Here, we see that we import the text, then remove instances of `"NA"`. 189 | This puts the text in a format that can be parsed by `arquero.fromCSV()`, which returns an arquero `Table`. 190 | Observable offers a table input for easier digestion: 191 | 192 | ::: nocheckbox 193 | ```{ojs} 194 | Inputs.table(inp) 195 | ``` 196 | ::: 197 | 198 | Next, we need a function to help us determine which columns can be used for grouping, and which for aggregation. 199 | 200 | This is a personal habit since trying to be more aware of functional programming, but whenever I make a function in Observable, I like to make the signature as prominent as possible. 201 | I use a variation of [Hindley-Miller notation](https://drboolean.gitbooks.io/mostly-adequate-guide-old/content/ch7.html), which is a fancy way of saying that I want to keep track of the types for the parameters and return-value: 202 | 203 | ```{ojs} 204 | //| output: all 205 | /* Table, (* -> Boolean) -> [String] 206 | * 207 | * Given an arquero table and a predicate-function, 208 | * return an array of strings corresponding to names of 209 | * columns that satisfy the predicate. 210 | * 211 | * This can be useful to identify which columns are strings 212 | * or numbers, etc. 213 | * 214 | * Note that null values are removed before the predicate 215 | * is applied. 216 | */ 217 | 218 | // comments disappear here 219 | columnNamesPredicate = function (data, predicate) { 220 | // but they seem to appear here 221 | const colNames = data.columnNames(); 222 | const keep = colNames.filter((x) => 223 | data 224 | .array(x) 225 | .filter((x) => !_.isNull(x)) 226 | .every(predicate) 227 | ); 228 | return keep; 229 | } 230 | ``` 231 | 232 | Note that the second parameter, `predicate`, is a function that takes any type of value and returns a boolean. 233 | If I wanted to return the names of string-columns, I would supply the Lodash function `_.isString`. 234 | (Also note that [Lodash](https://lodash.com/), which includes some functional tools, is a part of the Observable standard library.) 235 | 236 | An arquero table is a object of arrays, just like R's data frame is a list of (most-often) vectors; it's a column-based approach. 237 | 238 | First, we get an array of `colNames`. 239 | Then we filter this array using another predicate function: 240 | 241 | - `data.array(x)`: given the array of values in the column named `x`, 242 | - `.filter((x) => !_.isNull(x))`: keep only those values that are not null, 243 | - `.every(predicate)`: return `true` if every value in the array satisfies the `predicate` function we supply. 244 | 245 | We return only those column names where our predicate function returns `true`. 246 | 247 | Let's try it out: 248 | 249 | ```{ojs} 250 | //| output: all 251 | columnNamesPredicate(inp, _.isString) 252 | ``` 253 | 254 | ```{ojs} 255 | //| output: all 256 | columnNamesPredicate(inp, _.isNumber) 257 | ``` 258 | 259 | We also need a function to build an arquero query-object based on our specification. 260 | 261 | ```{ojs} 262 | //| output: all 263 | 264 | /* [String], [String], String -> Object 265 | * 266 | * Given an array of column names for grouping, an array of 267 | * column names for aggregations, and the name of an aggregation 268 | * function, return an object used to construct an Arquero query. 269 | * 270 | * The query will group by `cols_group`, then rollup (aggregate) 271 | * over `cols_agg`, using the function identified using `func_agg`. 272 | */ 273 | buildQueryObject = function (cols_group, cols_agg, func_agg) { 274 | const values = cols_agg.reduce( 275 | (acc, val) => ({ 276 | ...acc, 277 | [val]: { expr: `(d) => aq.op.${func_agg}(d["${val}"])`, func: true } 278 | }), 279 | {} 280 | ); 281 | 282 | const queryObject = { 283 | verbs: [ 284 | { verb: "groupby", keys: cols_group }, 285 | { verb: "rollup", values: values } 286 | ] 287 | }; 288 | 289 | return queryObject; 290 | } 291 | ``` 292 | 293 | There are two operations in this query: 294 | 295 | - `"groupby"`, where we use the `cols_group`. 296 | - `"rollup"`, where we build another object to specify the aggregation. 297 | 298 | The rollup component is the one we want to make sure of. 299 | If our aggregation function is `min`, and our aggregtion columns are `["bill_length_mm", "bill_depth_mm"]`, then the rollup specification should be: 300 | 301 | ``` js 302 | { 303 | bill_length_mm: {expr: `(d) => aq.op.min(d["bill_length_mm"])`, func: true }, 304 | bill_depth_mm: {expr: `(d) => aq.op.min(d["bill_depth_mm"])`, func: true } 305 | } 306 | ``` 307 | 308 | First we'll test the function, then I'll (try to) explain it: 309 | 310 | ```{ojs} 311 | //| output: all 312 | testQuery = buildQueryObject(["island"], ["bill_length_mm", "bill_depth_mm"], "min") 313 | ``` 314 | 315 | The `"rollup"` element seems to be working well. 316 | 317 | In arquero, for rollup (aggregation) operations: 318 | 319 | - The object's names are column names in the resulting table. 320 | - The object's values are expressed as functions. 321 | - the function takes the "data frame" as an argument; you can subset the data frame by column-name. 322 | - for security reasons, by default, arquero makes only certain operations available by default; these operations are contained in the `op` object. 323 | 324 | We can build the rollup object by using a `reduce()` function on the `cols_group` array: 325 | 326 | - The accumulator is initalized with an empty object, `{}`. 327 | - For each value ,`val`, in the `cols_group` array, given the accumulator, `acc`: 328 | - return a new object containing `acc` and a new named element. 329 | 330 | It can be a lot to absorb JavaScript, functional programming, and the peculiarities of arquero *all at once*. 331 | Keep in mind that you can apply the functional programming you learned using purrr, and your knowledge of how `group_by()` and `summarise()` work in dplyr. 332 | 333 | Here's the equivalent in R, using purrr and rlang: 334 | 335 | ``` r 336 | reducer <- function(acc, val, func) { 337 | 338 | mapped <- 339 | rlang::list2( 340 | "{val}" := list( 341 | expr = glue::glue('(d) => aq.op.{func}(d["{val}"])'), 342 | func = TRUE 343 | ) 344 | ) 345 | 346 | c(acc, mapped) 347 | } 348 | 349 | values <- purrr::reduce(cols_agg, reducer, func = func_agg, .init = list()) 350 | ``` 351 | 352 | This gets heavy because we have to use `rlang::list2()` to interpolate the names: `"{val}" :=`. 353 | 354 | Let's try out this query, evaluating it using our `inp` data: 355 | 356 | ```{ojs} 357 | //| output: all 358 | testAgg = aq 359 | .queryFrom(testQuery) 360 | .evaluate(inp); 361 | ``` 362 | 363 | ::: nocheckbox 364 | ```{ojs} 365 | Inputs.table(testAgg) 366 | ``` 367 | ::: 368 | 369 | We don't have the same check here to validate the aggregation function. 370 | Security considerations are a little bit different when using Observable. 371 | Because Observable runs this app entirely in the user's browser, there is no server component. 372 | Thus, the user is free to run whatever code they like - it's a bit like an IDE in that respect. 373 | 374 | There are some considerations around protecting secrets, but these do not apply to this app. 375 | 376 | ### Showcase 377 | 378 | Here's where we show what the notebook can do. 379 | 380 | To give the full effect here, I'll hide the code (you can unhide): 381 | 382 | #### Input data {.unnumbered} 383 | 384 | ::: nocheckbox 385 | ```{ojs} 386 | //| code-fold: true 387 | viewof table_inp = Inputs.table(inp) 388 | ``` 389 | ::: 390 | 391 | #### Controls {.unnumbered} 392 | 393 | ```{ojs} 394 | //| code-fold: true 395 | viewof cols_group = Inputs.select(columnNamesPredicate(inp, _.isString), { 396 | label: "Grouping columns", 397 | multiple: true 398 | }) 399 | ``` 400 | 401 | ```{ojs} 402 | //| code-fold: true 403 | viewof cols_agg = Inputs.select(columnNamesPredicate(inp, _.isNumber), { 404 | label: "Aggregation columns", 405 | multiple: true 406 | }) 407 | ``` 408 | 409 | ```{ojs} 410 | //| code-fold: true 411 | viewof func_agg = Inputs.select(["mean", "min", "max"], { 412 | label: "Aggregation function", 413 | multiple: false 414 | }) 415 | ``` 416 | 417 | ```{ojs} 418 | //| code-fold: true 419 | viewof agg = Inputs.button("Submit", { 420 | value: aq.table(), 421 | reduce: () => { 422 | return aq 423 | .queryFrom(buildQueryObject(cols_group, cols_agg, func_agg)) 424 | .evaluate(inp); 425 | } 426 | }) 427 | ``` 428 | 429 | #### Output data {.unnumbered} 430 | 431 | ::: nocheckbox 432 | ```{ojs} 433 | //| code-fold: true 434 | viewof table_agg = Inputs.table(agg) 435 | ``` 436 | ::: 437 | 438 | ------------------------------------------------------------------------ 439 | 440 | The only new concept here is the button. 441 | The *view* is the button, but the *value* is the aggregated table. 442 | The two are joined by a `reduce` option, a function that is run whenever the button is clicked. 443 | 444 | The reduce function: 445 | 446 | - builds the query 447 | - runs the query on the `inp` table 448 | - returns the aggregated table 449 | 450 | We delay the execution of the query by "hiding" it in a function. 451 | -------------------------------------------------------------------------------- /shiny.qmd: -------------------------------------------------------------------------------- 1 | # Shiny 2 | 3 | Shiny was my introduction to reactive programming. Like many folks, I started by hacking to "get stuff working"; this is a perfectly-honorable path. Then, I watched Joe Cheng's tutorials ([Part 1](https://www.rstudio.com/resources/shiny-dev-con/reactivity-pt-1-joe-cheng/), [Part 2](https://www.rstudio.com/resources/shiny-dev-con/reactivity-pt-2/)), in which he explained some of the theory behind Shiny. These talks started me on a path that completely changed my persepective and, eventually, my abilities as a programmer. 4 | 5 | This chapter is meant to be a review of Shiny; we will: 6 | 7 | - touch on some of the principles I learned from Joe's talks. 8 | - show how these principles are implemented the [demonstration app](https://ijlyttle.shinyapps.io/aggregate-local). 9 | 10 | ## Principles 11 | 12 | These are some things to keep in mind to help you write more-understandable and predictable Shiny apps. 13 | 14 | ### Pure functions vs. side effects 15 | 16 | This is the single biggest concept I have learned as a programmer, and I learned it relatively late in my career. 17 | 18 | A pure function has two properties: 19 | 20 | - given the same set of arguments, it *always* returns the same value. 21 | - it makes no changes outside of its scope. 22 | 23 | This can provide us some big benefits: 24 | 25 | - it doesn't matter where or how the return value is computed, we can rely on getting the same answer. 26 | - we don't have to worry about the environment changing as a result of calling the function. 27 | 28 | Here's a couple of examples of pure functions: 29 | 30 | ```{r pure_functions, eval=FALSE} 31 | function(x) { 32 | x**2 - 1 33 | } 34 | 35 | function(x) { 36 | paste(x, " using a pure function.") 37 | } 38 | ``` 39 | 40 | Pure functions are relatively striaghtforward to test because the output depends only on the inputs. 41 | 42 | *Side effects* is a catch-all term for when a function's behavior either: 43 | 44 | - depends on something not passed in as an argument. 45 | - changes the something outside of its scope, e.g.: writes a file, displays a plot. 46 | 47 | Here's a couple of functions that either depend on or cause side effects: 48 | 49 | ```{r side_effects, eval=FALSE} 50 | # return value depends on the *contents* of the file, not just file_name 51 | function(file_name) { 52 | read.csv(file_name) 53 | } 54 | 55 | # this might make a change in a remote service 56 | function(url, data) { 57 | 58 | h <- curl::new_handle() 59 | curl::handle_setform(h, data) 60 | 61 | curl::curl(url) 62 | } 63 | ``` 64 | 65 | Aside from being non-deterministic, functions with side effects can take a long time to execute. 66 | 67 | Of course, side effects are not necessarily bad things, but we need to be aware of them. Your Shiny server-function will make much more sense, and be much easier to debug, if you recognize pure functions and side effects. 68 | 69 | ### Reactives vs. observers 70 | 71 | Shiny server-functions provide two broad mechanisms for updating the state of your app: 72 | 73 | - `reactive()`: these return values, and work well with pure functions. In other words, the returned value depends only on the reactive values it depends on. 74 | 75 | - `observe()`: there is no return value; instead, these cause side-effects. Very often, the effect is to change something in the UI, such as the choices in an input, or to render a plot. 76 | 77 | In Shiny, reactive expressions are designed to run quickly and often; observers are designed to be run sparingly. 78 | 79 | ### Using tidyverse functions 80 | 81 | The tidyverse is designed with interactive programming in mind. It is meant to support code like this, without a lot of quotes or namespace qualifiers: 82 | 83 | ```{r tidy, eval=FALSE} 84 | penguins |> 85 | group_by(island, sex) |> 86 | summarise(bill_length_mm = mean(bill_length_mm)) 87 | ``` 88 | 89 | In Shiny, variable (column) names in data frames are expressed as strings, rather than as bare variable-names. As well, in Shiny, we may want to `summarise()` an arbitrary set of variables. Thus, it can be a challenge to use tidyverse code in Shiny. 90 | 91 | It should not surprise us that the tidyverse offers tools to address this situation: 92 | 93 | - `` is a set of tools to select variables within a data frame. Functions that use `` include `dplyr::select()`, `tidyr::pivot_longer()`. Of particular use in Shiny are the selection helpers for strings: `dplyr::any_of()` and `dplyr::all_of()`. 94 | - `across()` lets us use a `` specification in a data-masking function. More concretely, it lets us `group_by()` or `summarize()` over an arbitrary set of variables in a data frame. 95 | - If you need to use data-masking with (by definition) a single variable, you can use subsetting with the `.data` pronoun, e.g. `ggplot2::aes(x = .data[[str_var_x]])`. 96 | 97 | ## Demonstration App 98 | 99 | The goal of this chapter is to highlight some design choices in the [source code](https://github.com/ijlyttle/reactivity-demo-shiny) of this [demonstration Shiny app](https://ijlyttle.shinyapps.io/aggregate-local). 100 | 101 | ### Description 102 | 103 | To start with, spend a few minutes playing with the [app](https://github.com/ijlyttle/reactivity-demo-shiny), while referring back to these diagrams: 104 | 105 | ![Reactivity diagram for Shiny demo-app](images/shiny-aggregate-local.svg){.filter} 106 | 107 | Each `input` and `output` you see in the diagram is a part of the UI of the app. The reactive expressions, in this case: `inp` and `agg`, are found only in the app's server-function. 108 | 109 | ![Legend: Reactivity diagram for Shiny demo-app](images/shiny-legend.svg){.filter} 110 | 111 | The solid lines indicate immediate downstream-evaluation if the upstream value changes; this is what we think of when we hear "reactivity". The dashed lines indicate that downstream-evaluation does not immediate follow an upstream change. For example, the reactive-expression `agg` is updated only when the `button` is pushed. 112 | 113 | Spend some time to study the app, to make sure that these diagrams agree with your understanding of how the app operates. In the following sections, we'll discuss how to implement in your Shiny code. 114 | 115 | ### Prelims 116 | 117 | In the rest of this chapter, we'll highlight the code used to make app, and the design choices behind the code. In the [repository](https://github.com/ijlyttle/reactivity-demo-shiny), there are a couple of files to pay attention to: 118 | 119 | app-aggregate-local.R 120 | R/ 121 | aggregate-local.R 122 | 123 | Here's the start of the app file, `app-aggregate-local.R`: 124 | 125 | ```{r prelims, eval=FALSE} 126 | library("shiny") 127 | 128 | # ------------------- 129 | # global functions 130 | # ------------------- 131 | # 132 | # created outside of reactive environment, making it easier: 133 | # - to test 134 | # - to migrate to a package 135 | source("./R/aggregate-local.R") 136 | ``` 137 | 138 | As you can see, it sources `R/aggregate-local.R`, which contains our helper functions. 139 | 140 | ### Helper functions 141 | 142 | Before writing a Shiny app, I like to write out a set of non-reactive functions that will do the "heavy lifting". To the extent possible, these are pure functions, which makes it easier to test. I keep these functions in an `R` folder alongside my app; here's a [link](https://github.com/ijlyttle/reactivity-demo-shiny/blob/main/R/aggregate-local.R) to the actual code. 143 | 144 | Just like in the app, we'll use the [palmerpenguins](https://allisonhorst.github.io/palmerpenguins/) dataset: 145 | 146 | ```{r penguins} 147 | # this is not part of the helper functions - it's for exposition here 148 | library("palmerpenguins") 149 | library("tibble") 150 | 151 | penguins 152 | ``` 153 | 154 | In fact, the first bit of code is not even a function. It is an enumeration of the choices for the aggregation function: 155 | 156 | ```{r agg_function_choices} 157 | # choices for aggregation functions 158 | agg_function_choices <- c("mean", "min", "max") 159 | ``` 160 | 161 | We'll use it in a few places, so I want to define it only once. 162 | 163 | Next, a couple of functions that, given a data frame, return the names of: 164 | 165 | - numerical variables 166 | - categorical variables 167 | 168 | You might quibble with how I've defined these here, but it works for me, for this example. 169 | 170 | ```{r cols_number} 171 | # given a data frame, return the names of numeric columns 172 | cols_number <- function(df) { 173 | df_select <- dplyr::select(df, where(~is.numeric(.x) | is.integer(.x)) ) 174 | names(df_select) 175 | } 176 | ``` 177 | 178 | ```{r cols_category} 179 | # given a data frame, return the names of string and factor columns 180 | cols_category <- function(df) { 181 | df_select <- dplyr::select(df, where(~is.character(.x) | is.factor(.x)) ) 182 | names(df_select) 183 | } 184 | ``` 185 | 186 | You may have noticed that I refer to functions using the package name, e.g. `dplyr::select()`. This is a habit I learned following Hadley Wickham; basically: 187 | 188 | - I like to be as explicit as possible when writing functions. It provides fewer opportunities for strange things to happen; I provide enough opportunities as it is. 189 | 190 | - The function is more ready to be included in a package. 191 | 192 | As advertised, testing (or at least spot-verification) is straightforward: 193 | 194 | ```{r penguins_numerical} 195 | cols_number(penguins) 196 | ``` 197 | 198 | ```{r penguins_category} 199 | cols_category(penguins) 200 | ``` 201 | 202 | Let's look at the aggregation function: 203 | 204 | ```{r group_aggregate} 205 | group_aggregate <- function(df, str_group, str_agg, str_fn_agg, 206 | str_fn_choices = agg_function_choices) { 207 | 208 | # validate the aggregation function 209 | stopifnot( 210 | str_fn_agg %in% str_fn_choices 211 | ) 212 | 213 | # get the aggregation function 214 | func <- get(str_fn_agg) 215 | 216 | df |> 217 | dplyr::group_by(dplyr::across(dplyr::all_of(str_group))) |> 218 | dplyr::summarise( 219 | dplyr::across(dplyr::all_of(str_agg), func, na.rm = TRUE) 220 | ) 221 | } 222 | ``` 223 | 224 | There's a few things I want to point out about this function: 225 | 226 | - Aside from the data frame, all the arguments are strings. It is designed for use with Shiny, not for interactive use. 227 | 228 | - We are using `agg_function_choices` to make sure that we won't execute arbitrary code. We turn the string into binding to a function using `get()`. 229 | 230 | - We use dplyr's `across()` function, which lets us use `select()` semantics in "data-masking" functions, e.g. `group_by()`, `summarise()`. 231 | 232 | - To select data-frame variables using strings, we use `all_of()`. 233 | 234 | For example if we were grouping by `"island"`, then aggregating over `"bill_length_mm"` and `"bill_depth_mm"` using `"mean"`, our interactive code might look like: 235 | 236 | ```{r aggregate_interactive} 237 | library("dplyr", quietly = TRUE) 238 | 239 | aggregate_interactive <- 240 | penguins |> 241 | group_by(island) |> 242 | summarise( 243 | bill_length_mm = mean(bill_length_mm, na.rm = TRUE), 244 | bill_depth_mm = mean(bill_depth_mm, na.rm = TRUE) 245 | ) 246 | 247 | aggregate_interactive 248 | ``` 249 | 250 | We can use this result to help verify that our "string" version is working: 251 | 252 | ```{r aggregate_string} 253 | aggregate_string <- group_aggregate( 254 | penguins, 255 | str_group = "island", 256 | str_agg = c("bill_length_mm", "bill_depth_mm"), 257 | str_fn_agg = "mean" 258 | ) 259 | 260 | identical(aggregate_interactive, aggregate_string) 261 | ``` 262 | 263 | ### UI 264 | 265 | The UI object is relatively straightforward; we use a `fluidPage()` with a narrower column for inputs and a wider column for outputs. 266 | 267 | To give a clearer view of the high-level structure of the page, I replaced the code for the inputs and outputs with `...`: 268 | 269 | ```{r eval=FALSE} 270 | library("shiny") 271 | 272 | ui <- fluidPage( 273 | titlePanel("Aggregator"), 274 | fluidRow( 275 | column( 276 | width = 4, 277 | wellPanel( 278 | h3("Aggregation"), 279 | ... 280 | )s 281 | ), 282 | column( 283 | width = 8, 284 | h3("Input data"), 285 | ... 286 | hr(), 287 | h3("Aggregated data"), 288 | ... 289 | ) 290 | ) 291 | ) 292 | ``` 293 | 294 | #### Inputs 295 | 296 | ```{r ui_inputs, eval=FALSE} 297 | wellPanel( 298 | h3("Aggregation"), 299 | selectizeInput( 300 | inputId = "cols_group", 301 | label = "Grouping columns", 302 | choices = c(), 303 | multiple = TRUE 304 | ), 305 | selectizeInput( 306 | inputId = "cols_agg", 307 | label = "Aggregation columns", 308 | choices = c(), 309 | multiple = TRUE 310 | ), 311 | selectizeInput( 312 | inputId = "func_agg", 313 | label = "Aggregation function", 314 | choices = agg_function_choices, 315 | multiple = FALSE 316 | ), 317 | actionButton( 318 | inputId = "button", 319 | label = "Submit" 320 | ) 321 | ) 322 | ``` 323 | 324 | Let's look more closely at `input$cols_group` (this also applies to `input$cols_agg`): 325 | 326 | ```{r cols_group, eval=FALSE} 327 | selectizeInput( 328 | inputId = "cols_group", 329 | label = "Grouping columns", 330 | choices = c(), 331 | multiple = TRUE 332 | ) 333 | ``` 334 | 335 | Note that `choices` is specified, initially, as an empty vector. The reactivity diagram for `cols_group` indicates that, we use an observer function to update this input. We'll do this in the server function, where we update the `choices`. 336 | 337 | #### Outputs 338 | 339 | The outputs are fairly strightforward; we are using `DT::DTOutput()` as placeholders for [DT DataTables](https://rstudio.github.io/DT/). 340 | 341 | ```{r ui_outputs, eval=FALSE} 342 | column( 343 | width = 8, 344 | h3("Input data"), 345 | DT::DTOutput( 346 | outputId = "table_inp" 347 | ), 348 | hr(), 349 | h3("Aggregated data"), 350 | DT::DTOutput( 351 | outputId = "table_agg" 352 | ) 353 | ) 354 | ``` 355 | 356 | ### Server function 357 | 358 | This may be a habit particular to me, but I like to organize a server-function into groups: 359 | 360 | ```{r server_overview, eval=FALSE} 361 | server <- function(input, output, session) { 362 | # input observers 363 | # reactive expressions and values 364 | # outputs 365 | } 366 | ``` 367 | 368 | #### Input observers 369 | 370 | There are two inputs: `cols_group` and `cols_agg`, whose `choices` change when the input data-frame changes. 371 | 372 | To make such a change, we use a Shiny `observe()`, which runs when any of its reactive dependencies change. An `observe()` does not return a value; instead, it causes a side-effect. In this case, it changes an input element in the DOM. 373 | 374 | The observers are substantially similar, so I'll show only `cols_group`: 375 | 376 | ```{r server_input_observers, eval=FALSE} 377 | observe({ 378 | # this runs whenever the parsed input data changes 379 | updateSelectizeInput( 380 | session, 381 | inputId = "cols_group", 382 | choices = cols_category(inp()) 383 | ) 384 | }) 385 | ``` 386 | 387 | Note that one of our helper functions, `cols_category()`, makes an appearance. The `choices` for the `cols_group` input are updated according to the names of the categorical variables in the data frame returned by `inp()`. 388 | 389 | #### Reactive expressions 390 | 391 | This app uses two reactive expressions: 392 | 393 | - `inp()`, which returns the input data-frame. 394 | - `agg()`, which returns the aggregated data-frame. 395 | 396 | ```{r inp, eval=FALSE} 397 | inp <- 398 | reactive({ 399 | palmerpenguins::penguins 400 | }) 401 | ``` 402 | 403 | For this app, we probably did not need to wrap `palmerpenguins::penguins` in a `reactive()`. I did this with future expansion in mind, where `inp()` could also return a data frame according to a choice, or even a data frame parsed from an uploaded CSV file. 404 | 405 | The reactive expression for `agg()`, the aggregated data-frame, is more interesting: 406 | 407 | ```{r agg, eval=FALSE} 408 | agg <- 409 | reactive({ 410 | 411 | req(input$func_agg %in% agg_function_choices) 412 | 413 | group_aggregate( 414 | inp(), 415 | str_group = input$cols_group, 416 | str_agg = input$cols_agg, 417 | str_fn_agg = input$func_agg 418 | ) 419 | }) |> 420 | bindEvent(input$button, ignoreNULL = TRUE, ignoreInit = TRUE) 421 | ``` 422 | 423 | The first thing we do in the reactive is make sure that the value of `input$func_agg` is among the choices we specified. I'm sure you noticed that this is an extra check. Although redundant, I am careful to validate using the same values: `agg_function_choices`. You can read more about input validation in the [security chapter](https://mastering-shiny.org/scaling-security.html) of Mastering Shiny. 424 | 425 | Then, we use our `group_aggregate()` helper function. For me, having tested it outside of Shiny helped me focus on getting the rest of the code working. 426 | 427 | The `reactive()` expression returns the data; the expression itself is piped to `bindEvent()`, which will run the `reactive()`, and return its value, only when the value of `input$button` changes. This is a relatively new pattern in Shiny; it appeared in v1.6.0. 428 | 429 | `bindEvent()` has a couple of options: 430 | 431 | - `ignoreNULL = FALSE`: the `reactive()` is not evaluated if `input$button` is zero. 432 | - `ignoreInit = FALSE`: the `reactive()` is not evaluated when the app is first initialized. 433 | 434 | In this case, the `reactive()` is evaluated only in response to a button-click. This can be a useful pattern if the `reactive()` contains a long-running computation, or a call to an external resource. You may also be interested in Shiny's `bindCache()` function. 435 | 436 | #### Outputs 437 | 438 | There two outputs: one for the `inp()` data, the other for the `agg()` data; each is a table output. 439 | 440 | These outputs are similar to one another; we'll focus on `output$table_inp`: 441 | 442 | ```{r output_inp, eval=FALSE} 443 | output$table_inp <- DT::renderDT(inp()) 444 | ``` 445 | 446 | The table output is a straightforward use of `DT::renderDT()`. 447 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Attribution 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution 4.0 International Public License 58 | 59 | By exercising the Licensed Rights (defined below), You accept and agree 60 | to be bound by the terms and conditions of this Creative Commons 61 | Attribution 4.0 International Public License ("Public License"). To the 62 | extent this Public License may be interpreted as a contract, You are 63 | granted the Licensed Rights in consideration of Your acceptance of 64 | these terms and conditions, and the Licensor grants You such rights in 65 | consideration of benefits the Licensor receives from making the 66 | Licensed Material available under these terms and conditions. 67 | 68 | 69 | Section 1 -- Definitions. 70 | 71 | a. Adapted Material means material subject to Copyright and Similar 72 | Rights that is derived from or based upon the Licensed Material 73 | and in which the Licensed Material is translated, altered, 74 | arranged, transformed, or otherwise modified in a manner requiring 75 | permission under the Copyright and Similar Rights held by the 76 | Licensor. For purposes of this Public License, where the Licensed 77 | Material is a musical work, performance, or sound recording, 78 | Adapted Material is always produced where the Licensed Material is 79 | synched in timed relation with a moving image. 80 | 81 | b. Adapter's License means the license You apply to Your Copyright 82 | and Similar Rights in Your contributions to Adapted Material in 83 | accordance with the terms and conditions of this Public License. 84 | 85 | c. Copyright and Similar Rights means copyright and/or similar rights 86 | closely related to copyright including, without limitation, 87 | performance, broadcast, sound recording, and Sui Generis Database 88 | Rights, without regard to how the rights are labeled or 89 | categorized. For purposes of this Public License, the rights 90 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 91 | Rights. 92 | 93 | d. Effective Technological Measures means those measures that, in the 94 | absence of proper authority, may not be circumvented under laws 95 | fulfilling obligations under Article 11 of the WIPO Copyright 96 | Treaty adopted on December 20, 1996, and/or similar international 97 | agreements. 98 | 99 | e. Exceptions and Limitations means fair use, fair dealing, and/or 100 | any other exception or limitation to Copyright and Similar Rights 101 | that applies to Your use of the Licensed Material. 102 | 103 | f. Licensed Material means the artistic or literary work, database, 104 | or other material to which the Licensor applied this Public 105 | License. 106 | 107 | g. Licensed Rights means the rights granted to You subject to the 108 | terms and conditions of this Public License, which are limited to 109 | all Copyright and Similar Rights that apply to Your use of the 110 | Licensed Material and that the Licensor has authority to license. 111 | 112 | h. Licensor means the individual(s) or entity(ies) granting rights 113 | under this Public License. 114 | 115 | i. Share means to provide material to the public by any means or 116 | process that requires permission under the Licensed Rights, such 117 | as reproduction, public display, public performance, distribution, 118 | dissemination, communication, or importation, and to make material 119 | available to the public including in ways that members of the 120 | public may access the material from a place and at a time 121 | individually chosen by them. 122 | 123 | j. Sui Generis Database Rights means rights other than copyright 124 | resulting from Directive 96/9/EC of the European Parliament and of 125 | the Council of 11 March 1996 on the legal protection of databases, 126 | as amended and/or succeeded, as well as other essentially 127 | equivalent rights anywhere in the world. 128 | 129 | k. You means the individual or entity exercising the Licensed Rights 130 | under this Public License. Your has a corresponding meaning. 131 | 132 | 133 | Section 2 -- Scope. 134 | 135 | a. License grant. 136 | 137 | 1. Subject to the terms and conditions of this Public License, 138 | the Licensor hereby grants You a worldwide, royalty-free, 139 | non-sublicensable, non-exclusive, irrevocable license to 140 | exercise the Licensed Rights in the Licensed Material to: 141 | 142 | a. reproduce and Share the Licensed Material, in whole or 143 | in part; and 144 | 145 | b. produce, reproduce, and Share Adapted Material. 146 | 147 | 2. Exceptions and Limitations. For the avoidance of doubt, where 148 | Exceptions and Limitations apply to Your use, this Public 149 | License does not apply, and You do not need to comply with 150 | its terms and conditions. 151 | 152 | 3. Term. The term of this Public License is specified in Section 153 | 6(a). 154 | 155 | 4. Media and formats; technical modifications allowed. The 156 | Licensor authorizes You to exercise the Licensed Rights in 157 | all media and formats whether now known or hereafter created, 158 | and to make technical modifications necessary to do so. The 159 | Licensor waives and/or agrees not to assert any right or 160 | authority to forbid You from making technical modifications 161 | necessary to exercise the Licensed Rights, including 162 | technical modifications necessary to circumvent Effective 163 | Technological Measures. For purposes of this Public License, 164 | simply making modifications authorized by this Section 2(a) 165 | (4) never produces Adapted Material. 166 | 167 | 5. Downstream recipients. 168 | 169 | a. Offer from the Licensor -- Licensed Material. Every 170 | recipient of the Licensed Material automatically 171 | receives an offer from the Licensor to exercise the 172 | Licensed Rights under the terms and conditions of this 173 | Public License. 174 | 175 | b. No downstream restrictions. You may not offer or impose 176 | any additional or different terms or conditions on, or 177 | apply any Effective Technological Measures to, the 178 | Licensed Material if doing so restricts exercise of the 179 | Licensed Rights by any recipient of the Licensed 180 | Material. 181 | 182 | 6. No endorsement. Nothing in this Public License constitutes or 183 | may be construed as permission to assert or imply that You 184 | are, or that Your use of the Licensed Material is, connected 185 | with, or sponsored, endorsed, or granted official status by, 186 | the Licensor or others designated to receive attribution as 187 | provided in Section 3(a)(1)(A)(i). 188 | 189 | b. Other rights. 190 | 191 | 1. Moral rights, such as the right of integrity, are not 192 | licensed under this Public License, nor are publicity, 193 | privacy, and/or other similar personality rights; however, to 194 | the extent possible, the Licensor waives and/or agrees not to 195 | assert any such rights held by the Licensor to the limited 196 | extent necessary to allow You to exercise the Licensed 197 | Rights, but not otherwise. 198 | 199 | 2. Patent and trademark rights are not licensed under this 200 | Public License. 201 | 202 | 3. To the extent possible, the Licensor waives any right to 203 | collect royalties from You for the exercise of the Licensed 204 | Rights, whether directly or through a collecting society 205 | under any voluntary or waivable statutory or compulsory 206 | licensing scheme. In all other cases the Licensor expressly 207 | reserves any right to collect such royalties. 208 | 209 | 210 | Section 3 -- License Conditions. 211 | 212 | Your exercise of the Licensed Rights is expressly made subject to the 213 | following conditions. 214 | 215 | a. Attribution. 216 | 217 | 1. If You Share the Licensed Material (including in modified 218 | form), You must: 219 | 220 | a. retain the following if it is supplied by the Licensor 221 | with the Licensed Material: 222 | 223 | i. identification of the creator(s) of the Licensed 224 | Material and any others designated to receive 225 | attribution, in any reasonable manner requested by 226 | the Licensor (including by pseudonym if 227 | designated); 228 | 229 | ii. a copyright notice; 230 | 231 | iii. a notice that refers to this Public License; 232 | 233 | iv. a notice that refers to the disclaimer of 234 | warranties; 235 | 236 | v. a URI or hyperlink to the Licensed Material to the 237 | extent reasonably practicable; 238 | 239 | b. indicate if You modified the Licensed Material and 240 | retain an indication of any previous modifications; and 241 | 242 | c. indicate the Licensed Material is licensed under this 243 | Public License, and include the text of, or the URI or 244 | hyperlink to, this Public License. 245 | 246 | 2. You may satisfy the conditions in Section 3(a)(1) in any 247 | reasonable manner based on the medium, means, and context in 248 | which You Share the Licensed Material. For example, it may be 249 | reasonable to satisfy the conditions by providing a URI or 250 | hyperlink to a resource that includes the required 251 | information. 252 | 253 | 3. If requested by the Licensor, You must remove any of the 254 | information required by Section 3(a)(1)(A) to the extent 255 | reasonably practicable. 256 | 257 | 4. If You Share Adapted Material You produce, the Adapter's 258 | License You apply must not prevent recipients of the Adapted 259 | Material from complying with this Public License. 260 | 261 | 262 | Section 4 -- Sui Generis Database Rights. 263 | 264 | Where the Licensed Rights include Sui Generis Database Rights that 265 | apply to Your use of the Licensed Material: 266 | 267 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 268 | to extract, reuse, reproduce, and Share all or a substantial 269 | portion of the contents of the database; 270 | 271 | b. if You include all or a substantial portion of the database 272 | contents in a database in which You have Sui Generis Database 273 | Rights, then the database in which You have Sui Generis Database 274 | Rights (but not its individual contents) is Adapted Material; and 275 | 276 | c. You must comply with the conditions in Section 3(a) if You Share 277 | all or a substantial portion of the contents of the database. 278 | 279 | For the avoidance of doubt, this Section 4 supplements and does not 280 | replace Your obligations under this Public License where the Licensed 281 | Rights include other Copyright and Similar Rights. 282 | 283 | 284 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 285 | 286 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 287 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 288 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 289 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 290 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 291 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 292 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 293 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 294 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 295 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 296 | 297 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 298 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 299 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 300 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 301 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 302 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 303 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 304 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 305 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 306 | 307 | c. The disclaimer of warranties and limitation of liability provided 308 | above shall be interpreted in a manner that, to the extent 309 | possible, most closely approximates an absolute disclaimer and 310 | waiver of all liability. 311 | 312 | 313 | Section 6 -- Term and Termination. 314 | 315 | a. This Public License applies for the term of the Copyright and 316 | Similar Rights licensed here. However, if You fail to comply with 317 | this Public License, then Your rights under this Public License 318 | terminate automatically. 319 | 320 | b. Where Your right to use the Licensed Material has terminated under 321 | Section 6(a), it reinstates: 322 | 323 | 1. automatically as of the date the violation is cured, provided 324 | it is cured within 30 days of Your discovery of the 325 | violation; or 326 | 327 | 2. upon express reinstatement by the Licensor. 328 | 329 | For the avoidance of doubt, this Section 6(b) does not affect any 330 | right the Licensor may have to seek remedies for Your violations 331 | of this Public License. 332 | 333 | c. For the avoidance of doubt, the Licensor may also offer the 334 | Licensed Material under separate terms or conditions or stop 335 | distributing the Licensed Material at any time; however, doing so 336 | will not terminate this Public License. 337 | 338 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 339 | License. 340 | 341 | 342 | Section 7 -- Other Terms and Conditions. 343 | 344 | a. The Licensor shall not be bound by any additional or different 345 | terms or conditions communicated by You unless expressly agreed. 346 | 347 | b. Any arrangements, understandings, or agreements regarding the 348 | Licensed Material not stated herein are separate from and 349 | independent of the terms and conditions of this Public License. 350 | 351 | 352 | Section 8 -- Interpretation. 353 | 354 | a. For the avoidance of doubt, this Public License does not, and 355 | shall not be interpreted to, reduce, limit, restrict, or impose 356 | conditions on any use of the Licensed Material that could lawfully 357 | be made without permission under this Public License. 358 | 359 | b. To the extent possible, if any provision of this Public License is 360 | deemed unenforceable, it shall be automatically reformed to the 361 | minimum extent necessary to make it enforceable. If the provision 362 | cannot be reformed, it shall be severed from this Public License 363 | without affecting the enforceability of the remaining terms and 364 | conditions. 365 | 366 | c. No term or condition of this Public License will be waived and no 367 | failure to comply consented to unless expressly agreed to by the 368 | Licensor. 369 | 370 | d. Nothing in this Public License constitutes or may be interpreted 371 | as a limitation upon, or waiver of, any privileges and immunities 372 | that apply to the Licensor or You, including from the legal 373 | processes of any jurisdiction or authority. 374 | 375 | 376 | ======================================================================= 377 | 378 | Creative Commons is not a party to its public 379 | licenses. Notwithstanding, Creative Commons may elect to apply one of 380 | its public licenses to material it publishes and in those instances 381 | will be considered the “Licensor.” The text of the Creative Commons 382 | public licenses is dedicated to the public domain under the CC0 Public 383 | Domain Dedication. Except for the limited purpose of indicating that 384 | material is shared under a Creative Commons public license or as 385 | otherwise permitted by the Creative Commons policies published at 386 | creativecommons.org/policies, Creative Commons does not authorize the 387 | use of the trademark "Creative Commons" or any other trademark or logo 388 | of Creative Commons without its prior written consent including, 389 | without limitation, in connection with any unauthorized modifications 390 | to any of its public licenses or any other arrangements, 391 | understandings, or agreements concerning use of licensed material. For 392 | the avoidance of doubt, this paragraph does not form part of the 393 | public licenses. 394 | 395 | Creative Commons may be contacted at creativecommons.org. 396 | -------------------------------------------------------------------------------- /dash.qmd: -------------------------------------------------------------------------------- 1 | # Dash 2 | 3 | Although it is a gross oversimplification, at first glance Dash seems like Python's answer to Shiny. 4 | 5 | Accordingly, the goals of this chapter are: 6 | 7 | - to show the ways the Dash is like Shiny. 8 | - to introduce the ways Dash is different from Shiny. 9 | - to help you form strategies for how to "think" about Dash. 10 | 11 | To provide some context, we will use this [demonstration app](https://aggregate-local.herokuapp.com), and examine [its code](https://github.com/ijlyttle/reactivity-demo-dash). 12 | 13 | ## Principles 14 | 15 | Both Shiny and Dash use the idea of a reactive graph, which indicates what things depend on what other things: 16 | 17 | - In Shiny, the reactive graph (what depends on what) is inferred using the code in reactive expressions. 18 | 19 | - In Dash, it is explicit, which is a mixed blessing. 20 | 21 | For Dash, this explicitness provides the flexibility to do a lot of things, but the price is that *you* have to specify: 22 | 23 | - all the DOM components (UI elements) in the layout. 24 | - each connection between components is governed by a callback function that you provide. 25 | 26 | This is substantially similar to Shiny: part of the Dash app is used to define the UI; the rest defines what happens on the server. 27 | 28 | However, there are a couple of big considerations: 29 | 30 | - the state of the app **cannot** be stored in the server application. 31 | - it is easiest to move you data around using JSON and base64-encoded data. 32 | 33 | This is different from Shiny, which stores the state of the application in the server function. 34 | Further, Shiny manages the serialization/de-serialization of data to/from the UI; with Dash, you have to manage that yourself. 35 | 36 | These are not insurmountable obstacles, as well see in the rest of this section. 37 | For example, one place to store the state is the user's browser, in the web-page (DOM) itself. 38 | 39 | ### Everything that exists is a component 40 | 41 | These first two subsections are an homage to the famous John Chambers quote: 42 | 43 | > To understand computations in R, two slogans are helpful: 44 | > 45 | > - Everything that exists is an object. 46 | > - Everything that happens is a function call. 47 | 48 | Similarly, everything that exists on Dash app's web page is a component. 49 | 50 | As we'll see in the demo, a Dash app contains a layout that you specify: 51 | 52 | ```{python, eval=FALSE} 53 | app.layout = html.div(...) 54 | ``` 55 | 56 | You need to fill in the `...`. 57 | A component might be a straightforward HTML element, or it might be a Dash component, where you define the attributes. 58 | 59 | The `html` object (imported from the `dash` package) behaves very similarly to R's htmltools package; they are both based on the HTML5 spec. 60 | 61 | We'll see more in the demo, but here's an example of a Dash component: 62 | 63 | ```{python eval=FALSE} 64 | dcc.Dropdown(id='cols-group', multi=True) 65 | ``` 66 | 67 | This is a [dropdown component](https://dash.plotly.com/dash-core-components/dropdown); we define the `id` and `multi` properties at definition. 68 | In this case, we don't define the `options` or `value` properties. We'll update the `options` dynamically, and let the user set the `value`. 69 | 70 | Like other components, dropdowns have a number of [properties](https://dash.plotly.com/dash-core-components/dropdown#dropdown-properties); we can set them either at initialization, as we did here, or we can set them using a callback. 71 | 72 | ### Everything that happens is a callback 73 | 74 | If you want something to happen in a Dash app, it has to happen in a callback function. 75 | Dash lets you write callbacks using Python. 76 | It also lets you write callbacks in JavaScript, but that gets beyond the scope of this book. 77 | 78 | We'll see this in more detail in the demo app, but a callback is a standard Python function with a decorator: 79 | 80 | ```{python eval=FALSE} 81 | @app.callback(Output('cols-group', 'options'), 82 | Input('inp', 'data')) 83 | def update_cols_group(data_records): 84 | return cols_choice(data_records, 'object') 85 | ``` 86 | 87 | The decorator, `@app.callback(...)` tells Dash which layout components to map to the function's inputs and outputs. 88 | When an `Input()` changes: 89 | 90 | - the browser calls the Dash server to run the callback function. 91 | - the Dash server runs the Python function. 92 | - the Dash server sends the `Output()` to the browser. 93 | - the browser updates the DOM. 94 | 95 | ### Server cannot store state 96 | 97 | Managing state is a pain. 98 | However, by remaining stateless, Dash is able to easily scale to as many server instances it needs because it does not matter which instance of a callback-function responds to which browser (user) making the call. 99 | 100 | Coming from Shiny, this might seem like a show-stopper; we are used to manipulating, then storing data using the server side of an app. 101 | But there are ways around this. 102 | It's not that you can't store the state - you just can't store it "here". 103 | Your options are: 104 | 105 | - store data in the DOM, then send it when needed. 106 | - store data in an external database, or the like. 107 | 108 | We'll use the first option here. 109 | Here's one of the components in our layout: 110 | 111 | ```{python eval=FALSE} 112 | dcc.Store(id='inp', data=penguins.to_dict('records')) 113 | ``` 114 | 115 | Note that this component is initialized using the `penguins` data, but that we are using pandas' `to_dict()` method. 116 | This is because the component will receive the data using JSON; it is stored in the DOM as a JavaScript object. 117 | 118 | ### Use JSON or base64 119 | 120 | The final thing to keep in mind is that when we communicate data between the browser DOM and the callback functions, it does not use native Python objects. Instead, from the Python callback-functions' perspective, data is serialized to JSON when sent to the DOM, and deserialized from JSON when received from the DOM. 121 | 122 | For Python dictionaries and lists containing numbers and strings, the serialization process is implied. 123 | 124 | There are (at least) a couple of conventions for serializing a data frame to JSON: by row or by column. 125 | 126 | Coming from R, you may think of a data frame as a list of vectors, each of the same length. 127 | This is column-based, for example: 128 | 129 | ```json 130 | { 131 | "species": ["Adelie", "Adelie"], 132 | "bill_length_mm": [39.1, 18.7] 133 | } 134 | ``` 135 | 136 | Alternatively, the row-based approach: 137 | 138 | ```json 139 | [ 140 | {"species": "Adelie", "bill_length_mm": 39.1}, 141 | {"species": "Adelie", "bill_length_mm": 18.7} 142 | ] 143 | ``` 144 | 145 | The row-based approach seems to be the convention in Dash; this is the approach used by D3. 146 | Here, we think of a data frame as a collection of *records*. 147 | To serialize from Pandas, we'll use `to_dict('records')`; to deserialize (import) into Pandas, we'll use `from_dict()`. 148 | 149 | In the context of the Python code, I'll refer to data formats as either: 150 | 151 | - *data-frame* format, i.e. Pandas data frame 152 | - *records* format, i.e. JSON collection of records 153 | 154 | The other option is to use base64 encoding; I have seen this used for uploading/downloading text files, e.g. CSV or JSON files. 155 | 156 | ## Demonstration app 157 | 158 | I've tried to outline, briefly, some of the principles used to build a Dash app; I think they will make more sense in the context of the Dash demo-app. 159 | 160 | ### Description 161 | 162 | Here is the reactive graph for the demonstration app. 163 | 164 | It's a little busier than the Shiny app; Dash forces the developer to be explicit, Shiny makes some things implicit. 165 | 166 | ![Reactivity diagram for Dash demo-app](images/dash-aggregate-local.svg){.filter} 167 | 168 | A few things to note about this diagram: 169 | 170 | - Each rectangle is a layout component; each circle is a callback function. 171 | 172 | - Components have properties, which are associated with arguments to callback functions. 173 | 174 | - When an argument to a callback function changes, the function is (re-)run. 175 | 176 | - The output(s) of callback functions are used to update the properties of components. 177 | 178 | - Functions are run, properties are updated, and so on, until the app "comes to rest". 179 | 180 | There are a few more things formalized in the diagram, but we'll get to them as we discuss the demo app: 181 | 182 | ![Legend: Reactivity diagram for Dash demo-app](images/dash-legend.svg){.filter} 183 | 184 | ### Prelims 185 | 186 | Like we did with Shiny, in the rest of this chapter, we'll highlight the code used to make app, and the design choices behind the code. 187 | In the [repository](https://github.com/ijlyttle/reactivity-demo-dash), there are a some files to pay attention to: 188 | 189 | ``` 190 | app-aggregate-local.py 191 | helpers/ 192 | __init__.py 193 | aggregate.py 194 | cols.py 195 | ``` 196 | 197 | Here's the app file, `app-aggregate-local.py`, with the components and callbacks removed: 198 | 199 | ```{python eval=FALSE} 200 | # Run this app with `python app-aggregate-local.py` and 201 | # visit http://127.0.0.1:8050/ in your web browser. 202 | 203 | import dash 204 | from dash.dependencies import Input, Output, State 205 | from dash import dash_table 206 | from dash import html 207 | from dash import dcc 208 | import dash_bootstrap_components as dbc 209 | 210 | import pandas as pd 211 | from palmerpenguins import load_penguins 212 | 213 | from helpers.aggregate import aggregate_df, agg_function_choices 214 | from helpers.cols import cols_choice, cols_header 215 | 216 | penguins = load_penguins() 217 | 218 | app = dash.Dash( 219 | __name__, 220 | external_stylesheets=[dbc.themes.BOOTSTRAP] 221 | ) 222 | 223 | # make `server` available to Heroku 224 | server = app.server 225 | 226 | # 227 | 228 | # 229 | 230 | if __name__ == '__main__': 231 | app.run_server(debug=True) 232 | ``` 233 | 234 | The first part of the file handles the imports. 235 | You will need to have installed (hopefully in your virtual environment): `dash`, `dash_bootstrap_components`, `pandas`, and `palmerpenguins`. 236 | For example: 237 | 238 | ``` 239 | > pip install dash 240 | ``` 241 | 242 | The remaining bits of the file set up our app: 243 | 244 | - Load the `penguins` data. 245 | 246 | - Create the `app`. 247 | This is the standard way to create a Dash app; note that we are using the Bootstrap styling. 248 | 249 | - We make the `app.server` available at the top level as `server`. 250 | This goes a little beyond the scope of this chapter, but we are doing this as a part of deploying the app on Heroku. 251 | 252 | - Components and callbacks, we'll look at these in detail in following sections. 253 | 254 | - A standard bit of code that tells Python to `app.runServer()` when we execute this file. 255 | 256 | We also have a `helpers` directory with some files. 257 | The `__init__.py` file is empty; its purpose is to signal to Python that there are `.py` files in this directory with functions that can be imported. 258 | 259 | ### Helper functions 260 | 261 | Just like in R, I like to write as much "vanilla" Python code as I can. 262 | I want to demonstrate to myself, as much as I can, that the "guts" of the app behaves as I expect. 263 | That way, when I'm building the components and the callbacks, I can narrow down the things that might be going wrong. 264 | 265 | ```{r} 266 | # using only to demonstrate code; not used in Python Dash. 267 | library("reticulate") 268 | use_virtualenv("./venv") 269 | ``` 270 | 271 | I'm reproducing this code in an Quarto document using the R engine, so I'm using the reticulate package to run Python. 272 | You will not need to do this to create a Dash app; you can work in a purely Python environment. 273 | 274 | In fact, if you are newish to Python, you might find the "Field Guide" appendix useful to get things set up. 275 | For Python programming, there is a great introduction at reticulate. 276 | 277 | Our first step is to get a look at the `penguins` dataset, to verify it's the same as we use in R. 278 | 279 | ```{python} 280 | # importing into RMarkdown environment for exposition 281 | import pandas as pd 282 | from palmerpenguins import load_penguins 283 | import pprint 284 | 285 | penguins = load_penguins() 286 | print(penguins) 287 | ``` 288 | 289 | Because we will tell Dash to move data back and forth using a dictionary, we'll make a variable that stores `penguins` as records: 290 | 291 | ```{python} 292 | penguins_records = penguins.to_dict('records') 293 | pprint.pp(penguins_records[0:2]) # just to get a flavor 294 | ``` 295 | 296 | The first function we'll verify is used to tell us, for a given a data frame and a given Pandas type, which columns are of that type. 297 | (In the app, we'll convert to records-format in the callback function.) 298 | 299 | ```{python} 300 | def cols_choice (df, include): 301 | 302 | return df.select_dtypes(include=include).columns.to_list() 303 | ``` 304 | 305 | Let's test the function. 306 | In Pandas, strings have type `'object'`: 307 | 308 | ```{python} 309 | cols_choice(penguins, 'object') 310 | ``` 311 | 312 | Numbers have type `'number'`: 313 | 314 | ```{python} 315 | cols_choice(penguins, 'number') 316 | ``` 317 | 318 | So far, so good. 319 | 320 | Next, we need a function to generate the (display) table properties. 321 | We need to return a list where each element is dictionary that describes a column in the data frame. 322 | As we'll see later, this is the format we need to specify the table headers. 323 | 324 | Note that we are *not* using the data-frame format in this function, so we can keep everything in records format. 325 | We use a trick by looking at only the first (well, zeroth) record. 326 | 327 | ```{python} 328 | def cols_header (data_records): 329 | 330 | if (len(data_records) == 0): 331 | return [] 332 | 333 | return [{'name': v, 'id': v} for v in data_records[0].keys()] 334 | ``` 335 | 336 | There's all sorts of ways to do this in Python. 337 | I'm a sucker for functional programming, so here's how to use a `map()` function. 338 | This returns a map object, so we need to change it to a list. 339 | 340 | ```{python eval=FALSE} 341 | list( 342 | map(lambda v: {'name': v, 'id': v}, data_records[0].keys()) 343 | ) 344 | ``` 345 | 346 | This works largely like R's `purrr::map()`, but in Python, we provide the function first, then the thing we are iterating over. 347 | Here's a purrr equivalent: 348 | 349 | ```{r eval=FALSE} 350 | purrr::map(names(data_records[1]), ~list(name = .x, id = .x)) 351 | ``` 352 | 353 | Anyhow, let's try out our Python function: 354 | 355 | ```{python} 356 | cols_header(penguins_records) 357 | ``` 358 | 359 | This seems OK. 360 | 361 | Finally, we need an aggregation function. 362 | We send a data frame, then some lists of column-names, and a string naming an aggregation-function. 363 | This is an exercise in Pandas - I think this works, but my lack of experience in Pandas suggests there may be a "better" way. 364 | We'll write this as in terms of data frames; we'll convert to/from records-format in the callback function. 365 | 366 | ```{python} 367 | agg_function_choices = ['mean', 'min', 'max'] 368 | 369 | def aggregate_df (df, cols_group, cols_agg, func_agg, 370 | str_fn_choices = agg_function_choices): 371 | 372 | if not func_agg in str_fn_choices: 373 | raise AssertionError(f"{func_agg} not a legal function-choice") 374 | 375 | if (cols_group != None): 376 | df = df.groupby(cols_group) 377 | 378 | if (cols_agg == None or len(cols_agg) == 0): 379 | return [] 380 | 381 | # dictionary, keys: column-names, values: function-name 382 | dict_agg = {i: func_agg for i in cols_agg} 383 | 384 | df = df.agg(dict_agg).reset_index() 385 | 386 | return df 387 | 388 | aggregate_df( 389 | penguins, 390 | cols_group = "island", 391 | cols_agg = ["bill_length_mm", "bill_depth_mm"], 392 | func_agg = "mean" 393 | ) 394 | ``` 395 | 396 | This may be interesting only to me, but there is a more "functional" way to write the statement: 397 | 398 | ```{python eval=FALSE} 399 | dict_agg = {i: func_agg for i in cols_agg} 400 | ``` 401 | 402 | Using a reducer: 403 | 404 | ```{python eval=FALSE} 405 | from functools import reduce 406 | 407 | dict_agg = reduce(lambda acc, val: dict(acc, **{val: func_agg}), cols_agg, {}) 408 | ``` 409 | 410 | In this situation, it's more verbose and, I think, less clear what is going on unless you are into functional programming. 411 | Please pardon this diversion. 412 | 413 | Confident that the "guts" of the app works, we move onto the components and the callbacks. 414 | 415 | ### Compmonent layout 416 | 417 | Let's start with the formatting. 418 | In the app, we need to create an `app.layout`, which will be the HTML used to render our app in the browser. 419 | Just like with Shiny, we're using Bootstrap, but we have to be a little more explicit. 420 | In Shiny, Bootstrap is "baked in"; in Dash, it's an add-on. 421 | 422 | Here's the "formatting" bits of the layout; the `...` represent Dash components that we'll discuss presently: 423 | 424 | ```{python eval=FALSE} 425 | app.layout = html.Div( 426 | className='container-fluid', 427 | children=[ 428 | ... 429 | html.H2('Aggregator'), 430 | html.Div( 431 | className='row', 432 | children =[ 433 | html.Div( 434 | className='col-sm-4', 435 | children=[ 436 | dbc.Card([ 437 | dbc.CardHeader('Aggregation'), 438 | dbc.CardBody([ 439 | ... 440 | ]) 441 | ]) 442 | ] 443 | ), 444 | html.Div( 445 | className='col-sm-8', 446 | children=[ 447 | html.H3('Input data'), 448 | ... 449 | html.Hr(), 450 | html.H3('Aggregated data'), 451 | ... 452 | ] 453 | ) 454 | ] 455 | ) 456 | ] 457 | ) 458 | ``` 459 | 460 | #### Data stores 461 | 462 | The first parts I want to highlight are the data stores: 463 | 464 | ```{python eval=FALSE} 465 | dcc.Store(id='inp', data=penguins.to_dict('records')), 466 | dcc.Store(id='agg', data=[]) 467 | ``` 468 | 469 | Recall that the server part of a Dash app cannot store its state. 470 | Instead, we will store the state, in our case: the input and aggregated data, in the DOM. 471 | Dash provides a `dcc.Store()` component; in its `data` property, we store the records-based data (note the use of `.to_dict('records')`. 472 | Also note that, just like Shiny, components each have an `id` property. 473 | 474 | 475 | #### Inputs 476 | 477 | Let's look at the components that specify the aggregation: 478 | 479 | ```{python eval=FALSE} 480 | dcc.Dropdown(id='cols-group', multi=True), 481 | dcc.Dropdown(id='cols-agg', multi=True), 482 | dbc.Select( 483 | id='func-agg', 484 | options=[{'label': v, 'value': v} for v in agg_function_choices], 485 | value=agg_function_choices[0] 486 | ), 487 | dbc.Button(id='button-agg', children='Submit', class_name='btn btn-secondary') 488 | ``` 489 | 490 | Here, we're using a couple of "standard" (`dcc`) dropdowns and a couple of Bootstrap (`dbc`) components. 491 | 492 | With the dropdowns, other than providing the `id`, we specify that multiple selections can be made. 493 | We don't populate the options; we'll do this using a callback. 494 | If we were only ever going to use one input-dataset, in this case `penguins`, it might make sense to populate the `options` when defining the component. 495 | Populating using a callback function makes our code more general, allowing the case where we could upload an abitrary dataset. 496 | Perhaps it wasn't necessary to follow the more-general approach here, but it allowed me to learn more about how Dash works. 497 | 498 | We define our `dbc.Select()` component completely in the layout. 499 | 500 | Note also that we can apply Bootstrap classes to`dbc.Button()`. 501 | What we might think of as a label is specified as the `children` of the button element. 502 | 503 | #### Outputs 504 | 505 | It remains to look at the data tables. 506 | There are two tables, identical in form; we will examine only one of them: 507 | 508 | ```{python eval=FALSE} 509 | dash_table.DataTable( 510 | id='table-inp', 511 | page_size=10, 512 | sort_action='native' 513 | ) 514 | ``` 515 | 516 | The properties here are straightforward: we need an `id`, we want to show ten entries at a time, `sort_action='native'` indicates that we want sorting to be available (and for Dash to take care of it). 517 | 518 | We will populate the data tables using callback functions. 519 | 520 | ### Callback functions 521 | 522 | In Dash, callback functions are just regular functions with decorators. 523 | Here's a very generic example: 524 | 525 | ```{python eval=FALSE} 526 | @app.callback(Output('output-component', 'output-property'), 527 | Input('input-component', 'input-property')) 528 | def some_function_name(x): 529 | y = some_function(x) 530 | 531 | return y 532 | ``` 533 | 534 | The decorator tells Dash how to build a function that wraps the "actual" function. 535 | We are not concerned with the implementation; we are interested in the interface. 536 | 537 | The decorator is this bit of code: `@app.callback()`. 538 | It takes a series of arguments which map to the function's parameters and return values: 539 | 540 | - `Output('output-component', 'output-property')` is mapped to the return value. 541 | - `Input('input-component', 'input-property')` is mapped to the `x` parameter. 542 | 543 | This tells Dash that whenever the `input-property` of the `input-component` changes, it should: 544 | 545 | - run the function using the `input-property` as an argument, then 546 | - send the return value to the `output-property` of the `output-component` 547 | 548 | We'll see some more-complex cases in the following examples. 549 | 550 | #### Inputs 551 | 552 | We have a couple of input components that need updating: the grouping columns and the aggregation columns. 553 | Each has its own callback, but they are virtually identical, so I'll describe only one. 554 | 555 | ```{python eval=FALSE} 556 | @app.callback(Output('cols-group', 'options'), 557 | Input('inp', 'data')) 558 | def update_cols_group(data_records): 559 | df = pd.DataFrame.from_dict(data_records) 560 | return cols_choice(df, 'object') 561 | ``` 562 | 563 | Whenever the `inp` `data` changes: 564 | 565 | - the function is called using the input data (in records form). 566 | - the function converts to data-frame format, then 567 | - uses our helper function to determine the columns that have string values. 568 | - it returns a list of column names, which Dash uses to update the `cols-group` `options`. 569 | 570 | #### Calculations 571 | 572 | The callback that performs the aggregation has more going on, but we'll get to the bottom of it. 573 | 574 | ```{python eval=FALSE} 575 | @app.callback(Output('agg', 'data'), 576 | Input('button-agg', 'n_clicks'), 577 | State('inp', 'data'), 578 | State('cols-group', 'value'), 579 | State('cols-agg', 'value'), 580 | State('func-agg', 'value'), 581 | prevent_initial_call=True) 582 | def aggregate(n_clicks, data_records, cols_group, cols_agg, func_agg): 583 | # create DataFrame 584 | df = pd.DataFrame.from_dict(data_records) 585 | 586 | # aggregate 587 | df_new = aggregate_df(df, cols_group, cols_agg, func_agg) 588 | 589 | # serialize DataFrame 590 | return df_new.to_dict('records') 591 | ``` 592 | 593 | A couple of things to sort through: 594 | 595 | - The function has five parameters; the decorator has one `Input()` and four instances of `State()`. 596 | 597 | - The decorator is provided an additional argument: `prevent_initial_call`. 598 | 599 | A `State()` is similar to an `Input()`; the difference that the function is **not** run in response to a `State()` change. 600 | In this case, the function is run *only* in response to the value of the button changing, i.e. being clicked. 601 | 602 | The `prevent_inital_call` argument describes itself well. 603 | By setting it to `True`, we ensure that the only way the aggregation function will be run is when the button is clicked. 604 | 605 | Finally note that we convert our input records into a data frame, then use records-format for the return value. 606 | 607 | #### Outputs 608 | 609 | The final set of callbacks is for our data tables. 610 | Again, although we have two data tables, the callbacks are virtually identical, so I'll highlight only one. 611 | 612 | ```{python eval=FALSE} 613 | @app.callback(Output('table-inp', 'columns'), 614 | Output('table-inp', 'data'), 615 | Input('inp', 'data')) 616 | def update_table_inp(data_records): 617 | return cols_header(data_records), data_records 618 | ``` 619 | 620 | The thing to note there is that Dash and Python support multiple return-values, mapping each to an `Output()`. 621 | 622 | This situation merits multiple return-values because a Dash data table has a properties for `data` and `columns` (headers). 623 | This information comes from the same source, so it made sense to use multiple return-values. 624 | 625 | Certainly, you could write two callbacks to do the same things; writing a single callback made more sense to me. 626 | Of course, you should do what makes sense to you. 627 | -------------------------------------------------------------------------------- /renv/activate.R: -------------------------------------------------------------------------------- 1 | 2 | local({ 3 | 4 | # the requested version of renv 5 | version <- "0.15.2" 6 | 7 | # the project directory 8 | project <- getwd() 9 | 10 | # figure out whether the autoloader is enabled 11 | enabled <- local({ 12 | 13 | # first, check config option 14 | override <- getOption("renv.config.autoloader.enabled") 15 | if (!is.null(override)) 16 | return(override) 17 | 18 | # next, check environment variables 19 | # TODO: prefer using the configuration one in the future 20 | envvars <- c( 21 | "RENV_CONFIG_AUTOLOADER_ENABLED", 22 | "RENV_AUTOLOADER_ENABLED", 23 | "RENV_ACTIVATE_PROJECT" 24 | ) 25 | 26 | for (envvar in envvars) { 27 | envval <- Sys.getenv(envvar, unset = NA) 28 | if (!is.na(envval)) 29 | return(tolower(envval) %in% c("true", "t", "1")) 30 | } 31 | 32 | # enable by default 33 | TRUE 34 | 35 | }) 36 | 37 | if (!enabled) 38 | return(FALSE) 39 | 40 | # avoid recursion 41 | if (identical(getOption("renv.autoloader.running"), TRUE)) { 42 | warning("ignoring recursive attempt to run renv autoloader") 43 | return(invisible(TRUE)) 44 | } 45 | 46 | # signal that we're loading renv during R startup 47 | options(renv.autoloader.running = TRUE) 48 | on.exit(options(renv.autoloader.running = NULL), add = TRUE) 49 | 50 | # signal that we've consented to use renv 51 | options(renv.consent = TRUE) 52 | 53 | # load the 'utils' package eagerly -- this ensures that renv shims, which 54 | # mask 'utils' packages, will come first on the search path 55 | library(utils, lib.loc = .Library) 56 | 57 | # check to see if renv has already been loaded 58 | if ("renv" %in% loadedNamespaces()) { 59 | 60 | # if renv has already been loaded, and it's the requested version of renv, 61 | # nothing to do 62 | spec <- .getNamespaceInfo(.getNamespace("renv"), "spec") 63 | if (identical(spec[["version"]], version)) 64 | return(invisible(TRUE)) 65 | 66 | # otherwise, unload and attempt to load the correct version of renv 67 | unloadNamespace("renv") 68 | 69 | } 70 | 71 | # load bootstrap tools 72 | `%||%` <- function(x, y) { 73 | if (is.environment(x) || length(x)) x else y 74 | } 75 | 76 | bootstrap <- function(version, library) { 77 | 78 | # attempt to download renv 79 | tarball <- tryCatch(renv_bootstrap_download(version), error = identity) 80 | if (inherits(tarball, "error")) 81 | stop("failed to download renv ", version) 82 | 83 | # now attempt to install 84 | status <- tryCatch(renv_bootstrap_install(version, tarball, library), error = identity) 85 | if (inherits(status, "error")) 86 | stop("failed to install renv ", version) 87 | 88 | } 89 | 90 | renv_bootstrap_tests_running <- function() { 91 | getOption("renv.tests.running", default = FALSE) 92 | } 93 | 94 | renv_bootstrap_repos <- function() { 95 | 96 | # check for repos override 97 | repos <- Sys.getenv("RENV_CONFIG_REPOS_OVERRIDE", unset = NA) 98 | if (!is.na(repos)) 99 | return(repos) 100 | 101 | # check for lockfile repositories 102 | repos <- tryCatch(renv_bootstrap_repos_lockfile(), error = identity) 103 | if (!inherits(repos, "error") && length(repos)) 104 | return(repos) 105 | 106 | # if we're testing, re-use the test repositories 107 | if (renv_bootstrap_tests_running()) 108 | return(getOption("renv.tests.repos")) 109 | 110 | # retrieve current repos 111 | repos <- getOption("repos") 112 | 113 | # ensure @CRAN@ entries are resolved 114 | repos[repos == "@CRAN@"] <- getOption( 115 | "renv.repos.cran", 116 | "https://cloud.r-project.org" 117 | ) 118 | 119 | # add in renv.bootstrap.repos if set 120 | default <- c(FALLBACK = "https://cloud.r-project.org") 121 | extra <- getOption("renv.bootstrap.repos", default = default) 122 | repos <- c(repos, extra) 123 | 124 | # remove duplicates that might've snuck in 125 | dupes <- duplicated(repos) | duplicated(names(repos)) 126 | repos[!dupes] 127 | 128 | } 129 | 130 | renv_bootstrap_repos_lockfile <- function() { 131 | 132 | lockpath <- Sys.getenv("RENV_PATHS_LOCKFILE", unset = "renv.lock") 133 | if (!file.exists(lockpath)) 134 | return(NULL) 135 | 136 | lockfile <- tryCatch(renv_json_read(lockpath), error = identity) 137 | if (inherits(lockfile, "error")) { 138 | warning(lockfile) 139 | return(NULL) 140 | } 141 | 142 | repos <- lockfile$R$Repositories 143 | if (length(repos) == 0) 144 | return(NULL) 145 | 146 | keys <- vapply(repos, `[[`, "Name", FUN.VALUE = character(1)) 147 | vals <- vapply(repos, `[[`, "URL", FUN.VALUE = character(1)) 148 | names(vals) <- keys 149 | 150 | return(vals) 151 | 152 | } 153 | 154 | renv_bootstrap_download <- function(version) { 155 | 156 | # if the renv version number has 4 components, assume it must 157 | # be retrieved via github 158 | nv <- numeric_version(version) 159 | components <- unclass(nv)[[1]] 160 | 161 | methods <- if (length(components) == 4L) { 162 | list( 163 | renv_bootstrap_download_github 164 | ) 165 | } else { 166 | list( 167 | renv_bootstrap_download_cran_latest, 168 | renv_bootstrap_download_cran_archive 169 | ) 170 | } 171 | 172 | for (method in methods) { 173 | path <- tryCatch(method(version), error = identity) 174 | if (is.character(path) && file.exists(path)) 175 | return(path) 176 | } 177 | 178 | stop("failed to download renv ", version) 179 | 180 | } 181 | 182 | renv_bootstrap_download_impl <- function(url, destfile) { 183 | 184 | mode <- "wb" 185 | 186 | # https://bugs.r-project.org/bugzilla/show_bug.cgi?id=17715 187 | fixup <- 188 | Sys.info()[["sysname"]] == "Windows" && 189 | substring(url, 1L, 5L) == "file:" 190 | 191 | if (fixup) 192 | mode <- "w+b" 193 | 194 | utils::download.file( 195 | url = url, 196 | destfile = destfile, 197 | mode = mode, 198 | quiet = TRUE 199 | ) 200 | 201 | } 202 | 203 | renv_bootstrap_download_cran_latest <- function(version) { 204 | 205 | spec <- renv_bootstrap_download_cran_latest_find(version) 206 | 207 | message("* Downloading renv ", version, " ... ", appendLF = FALSE) 208 | 209 | type <- spec$type 210 | repos <- spec$repos 211 | 212 | info <- tryCatch( 213 | utils::download.packages( 214 | pkgs = "renv", 215 | destdir = tempdir(), 216 | repos = repos, 217 | type = type, 218 | quiet = TRUE 219 | ), 220 | condition = identity 221 | ) 222 | 223 | if (inherits(info, "condition")) { 224 | message("FAILED") 225 | return(FALSE) 226 | } 227 | 228 | # report success and return 229 | message("OK (downloaded ", type, ")") 230 | info[1, 2] 231 | 232 | } 233 | 234 | renv_bootstrap_download_cran_latest_find <- function(version) { 235 | 236 | # check whether binaries are supported on this system 237 | binary <- 238 | getOption("renv.bootstrap.binary", default = TRUE) && 239 | !identical(.Platform$pkgType, "source") && 240 | !identical(getOption("pkgType"), "source") && 241 | Sys.info()[["sysname"]] %in% c("Darwin", "Windows") 242 | 243 | types <- c(if (binary) "binary", "source") 244 | 245 | # iterate over types + repositories 246 | for (type in types) { 247 | for (repos in renv_bootstrap_repos()) { 248 | 249 | # retrieve package database 250 | db <- tryCatch( 251 | as.data.frame( 252 | utils::available.packages(type = type, repos = repos), 253 | stringsAsFactors = FALSE 254 | ), 255 | error = identity 256 | ) 257 | 258 | if (inherits(db, "error")) 259 | next 260 | 261 | # check for compatible entry 262 | entry <- db[db$Package %in% "renv" & db$Version %in% version, ] 263 | if (nrow(entry) == 0) 264 | next 265 | 266 | # found it; return spec to caller 267 | spec <- list(entry = entry, type = type, repos = repos) 268 | return(spec) 269 | 270 | } 271 | } 272 | 273 | # if we got here, we failed to find renv 274 | fmt <- "renv %s is not available from your declared package repositories" 275 | stop(sprintf(fmt, version)) 276 | 277 | } 278 | 279 | renv_bootstrap_download_cran_archive <- function(version) { 280 | 281 | name <- sprintf("renv_%s.tar.gz", version) 282 | repos <- renv_bootstrap_repos() 283 | urls <- file.path(repos, "src/contrib/Archive/renv", name) 284 | destfile <- file.path(tempdir(), name) 285 | 286 | message("* Downloading renv ", version, " ... ", appendLF = FALSE) 287 | 288 | for (url in urls) { 289 | 290 | status <- tryCatch( 291 | renv_bootstrap_download_impl(url, destfile), 292 | condition = identity 293 | ) 294 | 295 | if (identical(status, 0L)) { 296 | message("OK") 297 | return(destfile) 298 | } 299 | 300 | } 301 | 302 | message("FAILED") 303 | return(FALSE) 304 | 305 | } 306 | 307 | renv_bootstrap_download_github <- function(version) { 308 | 309 | enabled <- Sys.getenv("RENV_BOOTSTRAP_FROM_GITHUB", unset = "TRUE") 310 | if (!identical(enabled, "TRUE")) 311 | return(FALSE) 312 | 313 | # prepare download options 314 | pat <- Sys.getenv("GITHUB_PAT") 315 | if (nzchar(Sys.which("curl")) && nzchar(pat)) { 316 | fmt <- "--location --fail --header \"Authorization: token %s\"" 317 | extra <- sprintf(fmt, pat) 318 | saved <- options("download.file.method", "download.file.extra") 319 | options(download.file.method = "curl", download.file.extra = extra) 320 | on.exit(do.call(base::options, saved), add = TRUE) 321 | } else if (nzchar(Sys.which("wget")) && nzchar(pat)) { 322 | fmt <- "--header=\"Authorization: token %s\"" 323 | extra <- sprintf(fmt, pat) 324 | saved <- options("download.file.method", "download.file.extra") 325 | options(download.file.method = "wget", download.file.extra = extra) 326 | on.exit(do.call(base::options, saved), add = TRUE) 327 | } 328 | 329 | message("* Downloading renv ", version, " from GitHub ... ", appendLF = FALSE) 330 | 331 | url <- file.path("https://api.github.com/repos/rstudio/renv/tarball", version) 332 | name <- sprintf("renv_%s.tar.gz", version) 333 | destfile <- file.path(tempdir(), name) 334 | 335 | status <- tryCatch( 336 | renv_bootstrap_download_impl(url, destfile), 337 | condition = identity 338 | ) 339 | 340 | if (!identical(status, 0L)) { 341 | message("FAILED") 342 | return(FALSE) 343 | } 344 | 345 | message("OK") 346 | return(destfile) 347 | 348 | } 349 | 350 | renv_bootstrap_install <- function(version, tarball, library) { 351 | 352 | # attempt to install it into project library 353 | message("* Installing renv ", version, " ... ", appendLF = FALSE) 354 | dir.create(library, showWarnings = FALSE, recursive = TRUE) 355 | 356 | # invoke using system2 so we can capture and report output 357 | bin <- R.home("bin") 358 | exe <- if (Sys.info()[["sysname"]] == "Windows") "R.exe" else "R" 359 | r <- file.path(bin, exe) 360 | args <- c("--vanilla", "CMD", "INSTALL", "--no-multiarch", "-l", shQuote(library), shQuote(tarball)) 361 | output <- system2(r, args, stdout = TRUE, stderr = TRUE) 362 | message("Done!") 363 | 364 | # check for successful install 365 | status <- attr(output, "status") 366 | if (is.numeric(status) && !identical(status, 0L)) { 367 | header <- "Error installing renv:" 368 | lines <- paste(rep.int("=", nchar(header)), collapse = "") 369 | text <- c(header, lines, output) 370 | writeLines(text, con = stderr()) 371 | } 372 | 373 | status 374 | 375 | } 376 | 377 | renv_bootstrap_platform_prefix <- function() { 378 | 379 | # construct version prefix 380 | version <- paste(R.version$major, R.version$minor, sep = ".") 381 | prefix <- paste("R", numeric_version(version)[1, 1:2], sep = "-") 382 | 383 | # include SVN revision for development versions of R 384 | # (to avoid sharing platform-specific artefacts with released versions of R) 385 | devel <- 386 | identical(R.version[["status"]], "Under development (unstable)") || 387 | identical(R.version[["nickname"]], "Unsuffered Consequences") 388 | 389 | if (devel) 390 | prefix <- paste(prefix, R.version[["svn rev"]], sep = "-r") 391 | 392 | # build list of path components 393 | components <- c(prefix, R.version$platform) 394 | 395 | # include prefix if provided by user 396 | prefix <- renv_bootstrap_platform_prefix_impl() 397 | if (!is.na(prefix) && nzchar(prefix)) 398 | components <- c(prefix, components) 399 | 400 | # build prefix 401 | paste(components, collapse = "/") 402 | 403 | } 404 | 405 | renv_bootstrap_platform_prefix_impl <- function() { 406 | 407 | # if an explicit prefix has been supplied, use it 408 | prefix <- Sys.getenv("RENV_PATHS_PREFIX", unset = NA) 409 | if (!is.na(prefix)) 410 | return(prefix) 411 | 412 | # if the user has requested an automatic prefix, generate it 413 | auto <- Sys.getenv("RENV_PATHS_PREFIX_AUTO", unset = NA) 414 | if (auto %in% c("TRUE", "True", "true", "1")) 415 | return(renv_bootstrap_platform_prefix_auto()) 416 | 417 | # empty string on failure 418 | "" 419 | 420 | } 421 | 422 | renv_bootstrap_platform_prefix_auto <- function() { 423 | 424 | prefix <- tryCatch(renv_bootstrap_platform_os(), error = identity) 425 | if (inherits(prefix, "error") || prefix %in% "unknown") { 426 | 427 | msg <- paste( 428 | "failed to infer current operating system", 429 | "please file a bug report at https://github.com/rstudio/renv/issues", 430 | sep = "; " 431 | ) 432 | 433 | warning(msg) 434 | 435 | } 436 | 437 | prefix 438 | 439 | } 440 | 441 | renv_bootstrap_platform_os <- function() { 442 | 443 | sysinfo <- Sys.info() 444 | sysname <- sysinfo[["sysname"]] 445 | 446 | # handle Windows + macOS up front 447 | if (sysname == "Windows") 448 | return("windows") 449 | else if (sysname == "Darwin") 450 | return("macos") 451 | 452 | # check for os-release files 453 | for (file in c("/etc/os-release", "/usr/lib/os-release")) 454 | if (file.exists(file)) 455 | return(renv_bootstrap_platform_os_via_os_release(file, sysinfo)) 456 | 457 | # check for redhat-release files 458 | if (file.exists("/etc/redhat-release")) 459 | return(renv_bootstrap_platform_os_via_redhat_release()) 460 | 461 | "unknown" 462 | 463 | } 464 | 465 | renv_bootstrap_platform_os_via_os_release <- function(file, sysinfo) { 466 | 467 | # read /etc/os-release 468 | release <- utils::read.table( 469 | file = file, 470 | sep = "=", 471 | quote = c("\"", "'"), 472 | col.names = c("Key", "Value"), 473 | comment.char = "#", 474 | stringsAsFactors = FALSE 475 | ) 476 | 477 | vars <- as.list(release$Value) 478 | names(vars) <- release$Key 479 | 480 | # get os name 481 | os <- tolower(sysinfo[["sysname"]]) 482 | 483 | # read id 484 | id <- "unknown" 485 | for (field in c("ID", "ID_LIKE")) { 486 | if (field %in% names(vars) && nzchar(vars[[field]])) { 487 | id <- vars[[field]] 488 | break 489 | } 490 | } 491 | 492 | # read version 493 | version <- "unknown" 494 | for (field in c("UBUNTU_CODENAME", "VERSION_CODENAME", "VERSION_ID", "BUILD_ID")) { 495 | if (field %in% names(vars) && nzchar(vars[[field]])) { 496 | version <- vars[[field]] 497 | break 498 | } 499 | } 500 | 501 | # join together 502 | paste(c(os, id, version), collapse = "-") 503 | 504 | } 505 | 506 | renv_bootstrap_platform_os_via_redhat_release <- function() { 507 | 508 | # read /etc/redhat-release 509 | contents <- readLines("/etc/redhat-release", warn = FALSE) 510 | 511 | # infer id 512 | id <- if (grepl("centos", contents, ignore.case = TRUE)) 513 | "centos" 514 | else if (grepl("redhat", contents, ignore.case = TRUE)) 515 | "redhat" 516 | else 517 | "unknown" 518 | 519 | # try to find a version component (very hacky) 520 | version <- "unknown" 521 | 522 | parts <- strsplit(contents, "[[:space:]]")[[1L]] 523 | for (part in parts) { 524 | 525 | nv <- tryCatch(numeric_version(part), error = identity) 526 | if (inherits(nv, "error")) 527 | next 528 | 529 | version <- nv[1, 1] 530 | break 531 | 532 | } 533 | 534 | paste(c("linux", id, version), collapse = "-") 535 | 536 | } 537 | 538 | renv_bootstrap_library_root_name <- function(project) { 539 | 540 | # use project name as-is if requested 541 | asis <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT_ASIS", unset = "FALSE") 542 | if (asis) 543 | return(basename(project)) 544 | 545 | # otherwise, disambiguate based on project's path 546 | id <- substring(renv_bootstrap_hash_text(project), 1L, 8L) 547 | paste(basename(project), id, sep = "-") 548 | 549 | } 550 | 551 | renv_bootstrap_library_root <- function(project) { 552 | 553 | prefix <- renv_bootstrap_profile_prefix() 554 | 555 | path <- Sys.getenv("RENV_PATHS_LIBRARY", unset = NA) 556 | if (!is.na(path)) 557 | return(paste(c(path, prefix), collapse = "/")) 558 | 559 | path <- renv_bootstrap_library_root_impl(project) 560 | if (!is.null(path)) { 561 | name <- renv_bootstrap_library_root_name(project) 562 | return(paste(c(path, prefix, name), collapse = "/")) 563 | } 564 | 565 | renv_bootstrap_paths_renv("library", project = project) 566 | 567 | } 568 | 569 | renv_bootstrap_library_root_impl <- function(project) { 570 | 571 | root <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT", unset = NA) 572 | if (!is.na(root)) 573 | return(root) 574 | 575 | type <- renv_bootstrap_project_type(project) 576 | if (identical(type, "package")) { 577 | userdir <- renv_bootstrap_user_dir() 578 | return(file.path(userdir, "library")) 579 | } 580 | 581 | } 582 | 583 | renv_bootstrap_validate_version <- function(version) { 584 | 585 | loadedversion <- utils::packageDescription("renv", fields = "Version") 586 | if (version == loadedversion) 587 | return(TRUE) 588 | 589 | # assume four-component versions are from GitHub; three-component 590 | # versions are from CRAN 591 | components <- strsplit(loadedversion, "[.-]")[[1]] 592 | remote <- if (length(components) == 4L) 593 | paste("rstudio/renv", loadedversion, sep = "@") 594 | else 595 | paste("renv", loadedversion, sep = "@") 596 | 597 | fmt <- paste( 598 | "renv %1$s was loaded from project library, but this project is configured to use renv %2$s.", 599 | "Use `renv::record(\"%3$s\")` to record renv %1$s in the lockfile.", 600 | "Use `renv::restore(packages = \"renv\")` to install renv %2$s into the project library.", 601 | sep = "\n" 602 | ) 603 | 604 | msg <- sprintf(fmt, loadedversion, version, remote) 605 | warning(msg, call. = FALSE) 606 | 607 | FALSE 608 | 609 | } 610 | 611 | renv_bootstrap_hash_text <- function(text) { 612 | 613 | hashfile <- tempfile("renv-hash-") 614 | on.exit(unlink(hashfile), add = TRUE) 615 | 616 | writeLines(text, con = hashfile) 617 | tools::md5sum(hashfile) 618 | 619 | } 620 | 621 | renv_bootstrap_load <- function(project, libpath, version) { 622 | 623 | # try to load renv from the project library 624 | if (!requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) 625 | return(FALSE) 626 | 627 | # warn if the version of renv loaded does not match 628 | renv_bootstrap_validate_version(version) 629 | 630 | # load the project 631 | renv::load(project) 632 | 633 | TRUE 634 | 635 | } 636 | 637 | renv_bootstrap_profile_load <- function(project) { 638 | 639 | # if RENV_PROFILE is already set, just use that 640 | profile <- Sys.getenv("RENV_PROFILE", unset = NA) 641 | if (!is.na(profile) && nzchar(profile)) 642 | return(profile) 643 | 644 | # check for a profile file (nothing to do if it doesn't exist) 645 | path <- renv_bootstrap_paths_renv("profile", profile = FALSE) 646 | if (!file.exists(path)) 647 | return(NULL) 648 | 649 | # read the profile, and set it if it exists 650 | contents <- readLines(path, warn = FALSE) 651 | if (length(contents) == 0L) 652 | return(NULL) 653 | 654 | # set RENV_PROFILE 655 | profile <- contents[[1L]] 656 | if (!profile %in% c("", "default")) 657 | Sys.setenv(RENV_PROFILE = profile) 658 | 659 | profile 660 | 661 | } 662 | 663 | renv_bootstrap_profile_prefix <- function() { 664 | profile <- renv_bootstrap_profile_get() 665 | if (!is.null(profile)) 666 | return(file.path("profiles", profile, "renv")) 667 | } 668 | 669 | renv_bootstrap_profile_get <- function() { 670 | profile <- Sys.getenv("RENV_PROFILE", unset = "") 671 | renv_bootstrap_profile_normalize(profile) 672 | } 673 | 674 | renv_bootstrap_profile_set <- function(profile) { 675 | profile <- renv_bootstrap_profile_normalize(profile) 676 | if (is.null(profile)) 677 | Sys.unsetenv("RENV_PROFILE") 678 | else 679 | Sys.setenv(RENV_PROFILE = profile) 680 | } 681 | 682 | renv_bootstrap_profile_normalize <- function(profile) { 683 | 684 | if (is.null(profile) || profile %in% c("", "default")) 685 | return(NULL) 686 | 687 | profile 688 | 689 | } 690 | 691 | renv_bootstrap_path_absolute <- function(path) { 692 | 693 | substr(path, 1L, 1L) %in% c("~", "/", "\\") || ( 694 | substr(path, 1L, 1L) %in% c(letters, LETTERS) && 695 | substr(path, 2L, 3L) %in% c(":/", ":\\") 696 | ) 697 | 698 | } 699 | 700 | renv_bootstrap_paths_renv <- function(..., profile = TRUE, project = NULL) { 701 | renv <- Sys.getenv("RENV_PATHS_RENV", unset = "renv") 702 | root <- if (renv_bootstrap_path_absolute(renv)) NULL else project 703 | prefix <- if (profile) renv_bootstrap_profile_prefix() 704 | components <- c(root, renv, prefix, ...) 705 | paste(components, collapse = "/") 706 | } 707 | 708 | renv_bootstrap_project_type <- function(path) { 709 | 710 | descpath <- file.path(path, "DESCRIPTION") 711 | if (!file.exists(descpath)) 712 | return("unknown") 713 | 714 | desc <- tryCatch( 715 | read.dcf(descpath, all = TRUE), 716 | error = identity 717 | ) 718 | 719 | if (inherits(desc, "error")) 720 | return("unknown") 721 | 722 | type <- desc$Type 723 | if (!is.null(type)) 724 | return(tolower(type)) 725 | 726 | package <- desc$Package 727 | if (!is.null(package)) 728 | return("package") 729 | 730 | "unknown" 731 | 732 | } 733 | 734 | renv_bootstrap_user_dir <- function(path) { 735 | dir <- renv_bootstrap_user_dir_impl(path) 736 | chartr("\\", "/", dir) 737 | } 738 | 739 | renv_bootstrap_user_dir_impl <- function(path) { 740 | 741 | # use R_user_dir if available 742 | tools <- asNamespace("tools") 743 | if (is.function(tools$R_user_dir)) 744 | return(tools$R_user_dir("renv", "cache")) 745 | 746 | # try using our own backfill for older versions of R 747 | envvars <- c("R_USER_CACHE_DIR", "XDG_CACHE_HOME") 748 | for (envvar in envvars) { 749 | root <- Sys.getenv(envvar, unset = NA) 750 | if (!is.na(root)) { 751 | path <- file.path(root, "R/renv") 752 | return(path) 753 | } 754 | } 755 | 756 | # use platform-specific default fallbacks 757 | if (Sys.info()[["sysname"]] == "Windows") 758 | file.path(Sys.getenv("LOCALAPPDATA"), "R/cache/R/renv") 759 | else if (Sys.info()[["sysname"]] == "Darwin") 760 | "~/Library/Caches/org.R-project.R/R/renv" 761 | else 762 | "~/.cache/R/renv" 763 | 764 | } 765 | 766 | renv_json_read <- function(file = NULL, text = NULL) { 767 | 768 | text <- paste(text %||% read(file), collapse = "\n") 769 | 770 | # find strings in the JSON 771 | pattern <- '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]' 772 | locs <- gregexpr(pattern, text)[[1]] 773 | 774 | # if any are found, replace them with placeholders 775 | replaced <- text 776 | strings <- character() 777 | replacements <- character() 778 | 779 | if (!identical(c(locs), -1L)) { 780 | 781 | # get the string values 782 | starts <- locs 783 | ends <- locs + attr(locs, "match.length") - 1L 784 | strings <- substring(text, starts, ends) 785 | 786 | # only keep those requiring escaping 787 | strings <- grep("[[\\]{}:]", strings, perl = TRUE, value = TRUE) 788 | 789 | # compute replacements 790 | replacements <- sprintf('"\032%i\032"', seq_along(strings)) 791 | 792 | # replace the strings 793 | mapply(function(string, replacement) { 794 | replaced <<- sub(string, replacement, replaced, fixed = TRUE) 795 | }, strings, replacements) 796 | 797 | } 798 | 799 | # transform the JSON into something the R parser understands 800 | transformed <- replaced 801 | transformed <- gsub("[[{]", "list(", transformed) 802 | transformed <- gsub("[]}]", ")", transformed) 803 | transformed <- gsub(":", "=", transformed, fixed = TRUE) 804 | text <- paste(transformed, collapse = "\n") 805 | 806 | # parse it 807 | json <- parse(text = text, keep.source = FALSE, srcfile = NULL)[[1L]] 808 | 809 | # construct map between source strings, replaced strings 810 | map <- as.character(parse(text = strings)) 811 | names(map) <- as.character(parse(text = replacements)) 812 | 813 | # convert to list 814 | map <- as.list(map) 815 | 816 | # remap strings in object 817 | remapped <- renv_json_remap(json, map) 818 | 819 | # evaluate 820 | eval(remapped, envir = baseenv()) 821 | 822 | } 823 | 824 | renv_json_remap <- function(json, map) { 825 | 826 | # fix names 827 | if (!is.null(names(json))) { 828 | lhs <- match(names(json), names(map), nomatch = 0L) 829 | rhs <- match(names(map), names(json), nomatch = 0L) 830 | names(json)[rhs] <- map[lhs] 831 | } 832 | 833 | # fix values 834 | if (is.character(json)) 835 | return(map[[json]] %||% json) 836 | 837 | # handle true, false, null 838 | if (is.name(json)) { 839 | text <- as.character(json) 840 | if (text == "true") 841 | return(TRUE) 842 | else if (text == "false") 843 | return(FALSE) 844 | else if (text == "null") 845 | return(NULL) 846 | } 847 | 848 | # recurse 849 | if (is.recursive(json)) { 850 | for (i in seq_along(json)) { 851 | json[i] <- list(renv_json_remap(json[[i]], map)) 852 | } 853 | } 854 | 855 | json 856 | 857 | } 858 | 859 | # load the renv profile, if any 860 | renv_bootstrap_profile_load(project) 861 | 862 | # construct path to library root 863 | root <- renv_bootstrap_library_root(project) 864 | 865 | # construct library prefix for platform 866 | prefix <- renv_bootstrap_platform_prefix() 867 | 868 | # construct full libpath 869 | libpath <- file.path(root, prefix) 870 | 871 | # attempt to load 872 | if (renv_bootstrap_load(project, libpath, version)) 873 | return(TRUE) 874 | 875 | # load failed; inform user we're about to bootstrap 876 | prefix <- paste("# Bootstrapping renv", version) 877 | postfix <- paste(rep.int("-", 77L - nchar(prefix)), collapse = "") 878 | header <- paste(prefix, postfix) 879 | message(header) 880 | 881 | # perform bootstrap 882 | bootstrap(version, libpath) 883 | 884 | # exit early if we're just testing bootstrap 885 | if (!is.na(Sys.getenv("RENV_BOOTSTRAP_INSTALL_ONLY", unset = NA))) 886 | return(TRUE) 887 | 888 | # try again to load 889 | if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { 890 | message("* Successfully installed and loaded renv ", version, ".") 891 | return(renv::load()) 892 | } 893 | 894 | # failed to download or load renv; warn the user 895 | msg <- c( 896 | "Failed to find an renv installation: the project will not be loaded.", 897 | "Use `renv::activate()` to re-initialize the project." 898 | ) 899 | 900 | warning(paste(msg, collapse = "\n"), call. = FALSE) 901 | 902 | }) 903 | --------------------------------------------------------------------------------