From 28810e3f10a9ac04a40cce05298365260ae01239 Mon Sep 17 00:00:00 2001 From: Roy Francis Date: Sun, 17 Mar 2024 18:59:16 +0100 Subject: [PATCH] Added shinylive example --- _extensions/quarto-ext/shinylive/README.md | 126 +++++ .../quarto-ext/shinylive/_extension.yml | 8 + .../resources/css/shinylive-quarto.css | 34 ++ .../quarto-ext/shinylive/shinylive.lua | 454 ++++++++++++++++++ documents/shinylive/index.qmd | 136 ++++++ index.qmd | 3 + 6 files changed, 761 insertions(+) create mode 100644 _extensions/quarto-ext/shinylive/README.md create mode 100644 _extensions/quarto-ext/shinylive/_extension.yml create mode 100644 _extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css create mode 100644 _extensions/quarto-ext/shinylive/shinylive.lua create mode 100644 documents/shinylive/index.qmd diff --git a/_extensions/quarto-ext/shinylive/README.md b/_extensions/quarto-ext/shinylive/README.md new file mode 100644 index 0000000..55bfed2 --- /dev/null +++ b/_extensions/quarto-ext/shinylive/README.md @@ -0,0 +1,126 @@ +# Shinylive package methods + +## Methods + +### R + +Interaction: + +``` +Rscript -e 'shinylive:::quarto_ext()' [methods] [args] +``` + +### Python + +Interaction: + +``` +shinylive [methods] [args] +``` + +## CLI Methods + +* `extension info` + * Package, version, asset version, and script paths information +* `extension base-htmldeps` + * Quarto html dependencies for the base shinylive integration +* `extension language-resources` + * Language specific resource files for the quarto html dependency named `shinylive` +* `extension app-resources` + * App specific resource files for the quarto html dependency named `shinylive` + +### CLI Interface +* `extension info` + * Prints information about the extension including: + * `version`: The version of the R package + * `assets_version`: The version of the web assets + * `scripts`: A list of paths scripts that are used by the extension, + mainly `codeblock-to-json` + * Example + ``` + { + "version": "0.1.0", + "assets_version": "0.2.0", + "scripts": { + "codeblock-to-json": "//shinylive-0.2.0/scripts/codeblock-to-json.js" + } + } + ``` +* `extension base-htmldeps` + * Prints the language agnostic quarto html dependencies as a JSON array. + * The first html dependency is the `shinylive` service workers. + * The second html dependency is the `shinylive` base dependencies. This + dependency will contain the core `shinylive` asset scripts (JS files + automatically sourced), stylesheets (CSS files that are automatically + included), and resources (additional files that the JS and CSS files can + source). + * Example + ``` + [ + { + "name": "shinylive-serviceworker", + "version": "0.2.0", + "meta": { "shinylive:serviceworker_dir": "." }, + "serviceworkers": [ + { + "source": "//shinylive-0.2.0/shinylive-sw.js", + "destination": "/shinylive-sw.js" + } + ] + }, + { + "name": "shinylive", + "version": "0.2.0", + "scripts": [{ + "name": "shinylive/load-shinylive-sw.js", + "path": "//shinylive-0.2.0/shinylive/load-shinylive-sw.js", + "attribs": { "type": "module" } + }], + "stylesheets": [{ + "name": "shinylive/shinylive.css", + "path": "//shinylive-0.2.0/shinylive/shinylive.css" + }], + "resources": [ + { + "name": "shinylive/shinylive.js", + "path": "//shinylive-0.2.0/shinylive/shinylive.js" + }, + ... # [ truncated ] + ] + } + ] + ``` +* `extension language-resources` + * Prints the language-specific resource files as JSON that should be added to the quarto html dependency. + * For r-shinylive, this includes the webr resource files + * For py-shinylive, this includes the pyodide and pyright resource files. + * Example + ``` + [ + { + "name": "shinylive/webr/esbuild.d.ts", + "path": "//shinylive-0.2.0/shinylive/webr/esbuild.d.ts" + }, + { + "name": "shinylive/webr/libRblas.so", + "path": "//shinylive-0.2.0/shinylive/webr/libRblas.so" + }, + ... # [ truncated ] + ] +* `extension app-resources` + * Prints app-specific resource files as JSON that should be added to the `"shinylive"` quarto html dependency. + * Currently, r-shinylive does not return any resource files. + * Example + ``` + [ + { + "name": "shinylive/pyodide/anyio-3.7.0-py3-none-any.whl", + "path": "//shinylive-0.2.0/shinylive/pyodide/anyio-3.7.0-py3-none-any.whl" + }, + { + "name": "shinylive/pyodide/appdirs-1.4.4-py2.py3-none-any.whl", + "path": "//shinylive-0.2.0/shinylive/pyodide/appdirs-1.4.4-py2.py3-none-any.whl" + }, + ... # [ truncated ] + ] + ``` diff --git a/_extensions/quarto-ext/shinylive/_extension.yml b/_extensions/quarto-ext/shinylive/_extension.yml new file mode 100644 index 0000000..01b4d68 --- /dev/null +++ b/_extensions/quarto-ext/shinylive/_extension.yml @@ -0,0 +1,8 @@ +name: shinylive +title: Embedded Shinylive applications +author: Winston Chang +version: 0.1.0 +quarto-required: ">=1.2.198" +contributes: + filters: + - shinylive.lua diff --git a/_extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css b/_extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css new file mode 100644 index 0000000..3b7cc3a --- /dev/null +++ b/_extensions/quarto-ext/shinylive/resources/css/shinylive-quarto.css @@ -0,0 +1,34 @@ +div.output-content, +div.shinylive-wrapper { + background-color: rgba(250, 250, 250, 0.65); + border: 1px solid rgba(233, 236, 239, 0.65); + border-radius: 0.5rem; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.04), 0px 3px 7px rgba(0, 0, 0, 0.04), + 0px 12px 30px rgba(0, 0, 0, 0.07); + margin-top: 32px; + margin-bottom: 32px; +} + +div.shinylive-wrapper { + margin: 1em 0; + border-radius: 8px; +} + +.shinylive-container { + background-color: #eeeff2; + min-height: auto; +} + +.shinylive-container > div { + box-shadow: none; +} + +.editor-container .cm-editor .cm-scroller { + font-size: 13px; + line-height: 1.5; +} + +iframe.app-frame { + /* Override the default margin from Bootstrap */ + margin-bottom: 0; +} diff --git a/_extensions/quarto-ext/shinylive/shinylive.lua b/_extensions/quarto-ext/shinylive/shinylive.lua new file mode 100644 index 0000000..e2828db --- /dev/null +++ b/_extensions/quarto-ext/shinylive/shinylive.lua @@ -0,0 +1,454 @@ +-- Notes: +-- * 2023/10/04 - Barret: +-- Always use `callShinyLive()` to call a shinylive extension. +-- `callPythonShinyLive()` and `callRShinyLive()` should not be used directly. +-- Instead, always use `callShinyLive()`. +-- * 2023/10/04 - Barret: +-- I could not get `error(msg)` to quit the current function execution and +-- bubble up the stack and stop. Instead, I am using `assert(false, msg)` to +-- achieve the desired behavior. Multi-line error messages should start with a +-- `\n` to keep the message in the same readable area. + + +-- `table` to organize flags to have code only run once. +local hasDoneSetup = { base = false, r = false, python = false, python_version = false } +-- `table` to store `{ version, assets_version }` for each language's extension. +-- If both `r` and `python` are used in the same document, then the +-- `assets_version` for each language must be the same. +local versions = { r = nil, python = nil } +-- Global variable for the codeblock-to-json.js script file location +local codeblockScript = nil +-- Global hash table to store app specific dependencies to avoid calling +-- `quarto.doc.attach_to_dependency()` multiple times for the same dependency. +local appSpecificDeps = {} + +-- Display error message and throw error w/ short message +-- @param msg: string Error message to be displayed +-- @param short_msg: string Error message to be thrown +function throw_quarto_error(err_msg, ...) + n = select("#", ...) + if n > 0 then + -- Display any meta information about the error + -- Add blank lines after msg for line separation for better readability + quarto.log.error(...) + else + quarto.log.error(err_msg .. "\n\n") + end + -- Add blank lines after short_msg for line separation for better readability + -- Use assert(false, msg) to quit the current function execution and + -- bubble up the stack and stop. Barret: I could not get this to work with `error(msg)`. + assert(false, err_msg .. "\n") +end + +-- Python specific method to call py-shinylive +-- @param args: list of string arguments to pass to py-shinylive +-- @param input: string to pipe into to py-shinylive +function callPythonShinylive(args, input) + -- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message + -- about installing shinylive python package. + local res + local status, err = pcall( + function() + res = pandoc.pipe("shinylive", args, input) + end + ) + + if not status then + throw_quarto_error( + "Error running 'shinylive' command. Perhaps you need to install / update the 'shinylive' Python package?", + "Error running 'shinylive' command. Perhaps you need to install / update the 'shinylive' Python package?\n", + "Error:\n", + err + ) + end + + return res +end + +-- R specific method to call {r-shinylive} +-- @param args: list of string arguments to pass to r-shinylive +-- @param input: string to pipe into to r-shinylive +function callRShinylive(args, input) + args = { "-e", + "shinylive:::quarto_ext()", + table.unpack(args) } + + -- Try calling `pandoc.pipe('Rscript', ...)` and if it fails, print a message + -- about installing shinylive R package. + local res + local status, err = pcall( + function() + res = pandoc.pipe("Rscript", args, input) + end + ) + + if not status then + throw_quarto_error( + "Error running 'Rscript' command. Perhaps you need to install / update the 'shinylive' R package?", + "Error running 'Rscript' command. Perhaps you need to install / update the 'shinylive' R package?\n", + "Error:\n", + err + ) + end + + return res +end + +-- Returns decoded object +-- @param language: "python" or "r" +-- @param args, input: see `callPythonShinylive` and `callRShinylive` +function callShinylive(language, args, input, parseJson) + if input == nil then + input = "" + end + if parseJson == nil then + parseJson = true + end + + local res + -- print("Calling " .. language .. " shinylive with args: ", args) + if language == "python" then + res = callPythonShinylive(args, input) + elseif language == "r" then + res = callRShinylive(args, input) + else + throw_quarto_error("internal - Unknown language: " .. language) + end + + if not parseJson then + return res + end + + -- Remove any unwanted output before the first curly brace or square bracket. + -- print("res: " .. string.sub(res, 1, math.min(string.len(res), 100)) .. "...") + local curly_start = string.find(res, "{", 0, true) + local brace_start = string.find(res, "[", 0, true) + local min_start + if curly_start == nil then + min_start = brace_start + elseif brace_start == nil then + min_start = curly_start + else + min_start = math.min(curly_start, brace_start) + end + if min_start == nil then + local res_str = res + if string.len(res) > 100 then + res_str = string.sub(res, 1, 100) .. "... [truncated]" + end + throw_quarto_error( + "Could not find start curly brace or start brace in " .. + language .. " shinylive response. Is JSON being returned from the " .. language .. " `shinylive` package?", + "Could not find start curly brace or start brace in " .. language .. " shinylive response.\n", + "JSON string being parsed:\n", + res_str + ) + end + if min_start > 1 then + res = string.sub(res, min_start) + end + + + -- Decode JSON object + local result + local status, err = pcall( + function() + result = quarto.json.decode(res) + end + ) + if not status then + throw_quarto_error( + "Error decoding JSON response from `shinylive` " .. language .. " package.", + "Error decoding JSON response from `shinylive` " .. language .. " package.\n", + "JSON string being parsed:\n", + res, + "Error:\n", + err + ) + end + return result +end + +function parseVersion(versionTxt) + local versionParts = {} + for part in string.gmatch(versionTxt, "%d+") do + table.insert(versionParts, tonumber(part)) + end + local ret = { + major = nil, + minor = nil, + patch = nil, + extra = nil, + length = #versionParts, + str = versionTxt + } + + if ret.length >= 1 then + ret.major = versionParts[1] + if ret.length >= 2 then + ret.minor = versionParts[2] + if ret.length >= 3 then + ret.patch = versionParts[3] + if ret.length >= 4 then + ret.extra = versionParts[4] + end + end + end + end + + return ret +end + +-- If verA > verB, return 1 +-- If verA == verB, return 0 +-- If verA < verB, return -1 +function compareVersions(verA, verB) + if verA.major == nil or verB.major == nil then + throw_quarto_error("Trying to compare an invalid version: " .. verA.str .. " or " .. verB.str) + end + + for index, key in ipairs({ "major", "minor", "patch", "extra" }) do + local partDiff = compareVersionPart(verA[key], verB[key]) + if partDiff ~= 0 then + return partDiff + end + end + + -- Equal! + return 0 +end + +function compareVersionPart(aPart, bPart) + if aPart == nil and bPart == nil then + return 0 + end + if aPart == nil then + return -1 + end + if bPart == nil then + return 1 + end + if aPart > bPart then + return 1 + elseif aPart < bPart then + return -1 + end + + -- Equal! + return 0 +end + +function ensurePyshinyliveVersion(language) + -- Quit early if not python + if language ~= "python" then + return + end + -- Quit early if already completed check + if hasDoneSetup.python_version then + return + end + hasDoneSetup.python_version = true + + -- Verify that min python shinylive version is met + pyShinyliveVersion = callShinylive(language, { "--version" }, "", false) + -- Remove trailing whitespace + pyShinyliveVersion = pyShinyliveVersion:gsub("%s+$", "") + -- Parse version into table + parsedVersion = parseVersion(pyShinyliveVersion) + + -- Verify that the version is at least 0.1.0 + if + (parsedVersion.length < 3) or + -- Major and minor values are 0. Ex: 0.0.18 + (parsedVersion.major == 0 and parsedVersion.minor == 0) + then + assert(false, + "\nThe shinylive Python package must be at least version v0.1.0 to be used in a Quarto document." .. + "\n\nInstalled Python Shinylive package version: " .. pyShinyliveVersion .. + "\n\nPlease upgrade the Python Shinylive package by running:" .. + "\n\tpip install --upgrade shinylive" .. + "\n\n(If you are using a virtual environment, please activate it before running the command above.)" + ) + end +end + +-- Do one-time setup for language agnostic html dependencies. +-- This should only be called once per document +-- @param language: "python" or "r" +function ensureBaseSetup(language) + -- Quit early if already done + if hasDoneSetup.base then + return + end + hasDoneSetup.base = true + + -- Find the path to codeblock-to-json.ts and save it for later use. + local infoObj = callShinylive(language, { "extension", "info" }) + -- Store the path to codeblock-to-json.ts for later use + codeblockScript = infoObj.scripts['codeblock-to-json'] + -- Store the version info for later use + versions[language] = { version = infoObj.version, assets_version = infoObj.assets_version } + + -- Add language-agnostic dependencies + local baseDeps = getShinyliveBaseDeps(language) + for idx, dep in ipairs(baseDeps) do + quarto.doc.add_html_dependency(dep) + end + + -- Add ext css dependency + quarto.doc.add_html_dependency( + { + name = "shinylive-quarto-css", + stylesheets = { "resources/css/shinylive-quarto.css" } + } + ) +end + +-- Do one-time setup for language specific html dependencies. +-- This should only be called once per document +-- @param language: "python" or "r" +function ensureLanguageSetup(language) + -- Min version check must be done first + ensurePyshinyliveVersion(language) + + -- Make sure the base setup is done before the langage setup + ensureBaseSetup(language) + + if hasDoneSetup[language] then + return + end + hasDoneSetup[language] = true + + -- Only get the asset version value if it hasn't been retrieved yet. + if versions[language] == nil then + local infoObj = callShinylive(language, { "extension", "info" }) + versions[language] = { version = infoObj.version, assets_version = infoObj.assets_version } + end + -- Verify that the r-shinylive and py-shinylive supported assets versions match + if + (versions.r and versions.python) and + ---@diagnostic disable-next-line: undefined-field + versions.r.assets_version ~= versions.python.assets_version + then + local parsedRAssetsVersion = parseVersion(versions.r.assets_version) + local parsedPythonAssetsVersion = parseVersion(versions.python.assets_version) + + local verDiff = compareVersions(parsedRAssetsVersion, parsedPythonAssetsVersion) + local verDiffStr = "" + if verDiff == 1 then + -- R shinylive supports higher version of assets. Upgrade python shinylive + verDiffStr = + "The currently installed python shinylive package supports a lower assets version, " .. + "therefore we recommend updating your python shinylive package to the latest version." + elseif verDiff == -1 then + -- Python shinylive supports higher version of assets. Upgrade R shinylive + verDiffStr = + "The currently installed R shinylive package supports a lower assets version, " .. + "therefore we recommend updating your R shinylive package to the latest version." + end + + throw_quarto_error( + "The shinylive R and Python packages must support the same Shinylive Assets version to be used in the same Quarto document.", + "The shinylive R and Python packages must support the same Shinylive Assets version to be used in the same Quarto document.\n", + "\n", + "Python shinylive package version: ", + ---@diagnostic disable-next-line: undefined-field + versions.python.version .. " ; Supported assets version: " .. versions.python.assets_version .. "\n", + "R shinylive package version: " .. + ---@diagnostic disable-next-line: undefined-field + versions.r.version .. " ; Supported assets version: " .. versions.r.assets_version .. "\n", + "\n", + verDiffStr .. "\n", + "\n", + "To update your R Shinylive package, run:\n", + "\tR -e \"install.packages('shinylive')\"\n", + "\n", + "To update your Python Shinylive package, run:\n", + "\tpip install --upgrade shinylive\n", + "(If you are using a virtual environment, please activate it before running the command above.)\n", + "\n" + ) + end + + -- Add language-specific dependencies + local langResources = callShinylive(language, { "extension", "language-resources" }) + for idx, resourceDep in ipairs(langResources) do + -- No need to check for uniqueness. + -- Each resource is only be added once and should already be unique. + quarto.doc.attach_to_dependency("shinylive", resourceDep) + end +end + +function getShinyliveBaseDeps(language) + -- Relative path from the current page to the root of the site. This is needed + -- to find out where shinylive-sw.js is, relative to the current page. + if quarto.project.offset == nil then + throw_quarto_error("The `shinylive` extension must be used in a Quarto project directory (with a _quarto.yml file).") + end + local deps = callShinylive( + language, + { "extension", "base-htmldeps", "--sw-dir", quarto.project.offset }, + "" + ) + return deps +end + +return { + { + CodeBlock = function(el) + if not el.attr then + -- Not a shinylive codeblock, return + return + end + + local language + if el.attr.classes:includes("{shinylive-r}") then + language = "r" + elseif el.attr.classes:includes("{shinylive-python}") then + language = "python" + else + -- Not a shinylive codeblock, return + return + end + -- Setup language and language-agnostic dependencies + ensureLanguageSetup(language) + + -- Convert code block to JSON string in the same format as app.json. + local parsedCodeblockJson = pandoc.pipe( + "quarto", + { "run", codeblockScript, language }, + el.text + ) + + -- This contains "files" and "quartoArgs" keys. + local parsedCodeblock = quarto.json.decode(parsedCodeblockJson) + + -- Find Python package dependencies for the current app. + local appDeps = callShinylive( + language, + { "extension", "app-resources" }, + -- Send as piped input to the shinylive command + quarto.json.encode(parsedCodeblock["files"]) + ) + + -- Add app specific dependencies + for idx, dep in ipairs(appDeps) do + if not appSpecificDeps[dep.name] then + appSpecificDeps[dep.name] = true + quarto.doc.attach_to_dependency("shinylive", dep) + end + end + + if el.attr.classes:includes("{shinylive-python}") then + el.attributes.engine = "python" + el.attr.classes = pandoc.List() + el.attr.classes:insert("shinylive-python") + elseif el.attr.classes:includes("{shinylive-r}") then + el.attributes.engine = "r" + el.attr.classes = pandoc.List() + el.attr.classes:insert("shinylive-r") + end + return el + end + } +} diff --git a/documents/shinylive/index.qmd b/documents/shinylive/index.qmd new file mode 100644 index 0000000..13db465 --- /dev/null +++ b/documents/shinylive/index.qmd @@ -0,0 +1,136 @@ +--- +pagetitle: "Shinylive" +title: "Shinylive" +subtitle: "Run shiny apps in the browser" +author: "Roy Francis" +date: last-modified +format: + html: + theme: materia + title-block-banner: true + toc: true + number-sections: true + code-tools: true + resources: + - shinylive-sw.js +filters: + - shinylive +--- + +## Set up + +For quarto, add quarto filter `shinylive`. + +```{bash} +#| eval: false +quarto add quarto-ext/shinylive +``` + +Add to filters in yaml: + +```yaml +filters: + - shinylive +``` + +R code block with shiny component must use language `shinylive-r` rather than `r`. Packages are installed using `webr`. Chunk option `#| standalone: true` must be defined. + +Here is the general structure of a shinylive chunk: + +```` +```{{shinylive-r}} +#| standalone: true + +webr::install("ggplot2") +library(ggplot2) + +ui <- fluidPage() +server <- function(input, output, session) {} +shinyApp(ui, server) +``` +```` + +## Embedded app + +::: {.callout-note} +The app can take a while to load. For full code, use code-tools, ie; click 'Code' at the top. +::: + +```{shinylive-r} +#| standalone: true +#| viewerHeight: 450 + +webr::install(c("ggplot2", "bslib", "palmerpenguins", "htmltools")) + +library(htmltools) +library(bslib) +library(ggplot2) +library(palmerpenguins) +data("penguins") + +pdata <- na.omit(penguins) +pc <- prcomp(pdata[,c("bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g")], center = TRUE, scale. = TRUE) +dfr <- cbind(pdata,as.data.frame(pc$x)) + +ui <- page_sidebar( + sidebar = sidebar( + selectInput("x", + label = "X axis", + choices = c("PC1","PC2","PC3","PC4"), + selected = "PC1" + ), + + selectInput("y", + label = "Y axis", + choices = c("PC1","PC2","PC3","PC4"), + selected = "PC2" + ), + selectInput("v", + label = "Color", + choices = c("species", "island", "sex"), + selected = "cut" + ) + ), + imageOutput("plot", height = "350px") +) + +server <- function(input, output, session) { + + output$plot <- renderImage({ + x <- input$x + y <- input$y + v <- input$v + + p <- ggplot(dfr, aes(x = !!sym(x), y = !!sym(y), col = !!sym(v))) + + geom_point() + + theme_bw() + + theme(legend.position = "top") + + file <- htmltools::capturePlot( + print(p), + tempfile(fileext = ".svg"), + grDevices::svg, + width = 4, + height = 4 + ) + + list(src = file) + }, deleteFile = TRUE) +} + +app <- shinyApp(ui = ui, server = server) +``` + +To convert existing shiny apps into shinylive apps, install `shinylive` R package locally: + +```{r} +#| eval: false +remotes::install_github("posit-dev/r-shinylive") +``` + +## Acknowledgements + +- +- +- + diff --git a/index.qmd b/index.qmd index 697d152..bc6993b 100644 --- a/index.qmd +++ b/index.qmd @@ -23,6 +23,9 @@ format: ### [WebR in HTML report using Quarto webr extension](documents/webr/index.qmd) ### [WebR in RevealJS presentation](documents/webr-revealjs/index.qmd) +## Shinylive +### [Shiny in the browser](documents/shinylive/index.qmd) + ## Other ### [Icons in quarto](documents/icons/index.qmd)