Skip navigation

Category Archives: R

I have a post coming on using base and {ggplot2} plots in VanillaJS WebR, but after posting some bits on social media regarding how slow {ggplot2} is to deal with, I had some “performance”-related inquiries, which led me down a rabbit hole that I’m, now, dragging y’all down into as well.

First, a preview of the aforementioned plot/graphics:

I encourage you to load both of them before continuuing to see why I was curious about package load times.

Getting A Package Into WebR: A Look At {ggplot2}

If we strip away all the cruft, this is the core way to install a package into WebR and make it available to a freshly minted WebR context:

import { WebR } from '/webr/webr.mjs';
globalThis.webR = new WebR({ WEBR_URL: "/webr/", SW_URL: "/w/bench/",});
await globalThis.webR.init();
await globalThis.webR.installPackages(['PACKAGE'])
await globalThis.webR.evalRVoid('library(PACKAGE)')

Let’s look at what happens in the browser during the call to installPackages() when PACKAGE is ggplot2:

Screen capture of DevTools showing ggplot2 dependent packages loading.

Screen capture of DevTools showing ggplot2 dependent packages loading.

Dependent libraries are sequentially loaded until we finally get to ggplot2 (foregoeing {} from now on). There are 28 packages for ggplot2 (including itself) and they have a really skewed package size distribution:

Min.   :   6K
1st Qu.: 108K
Median : 481K
Mean   : 950K
3rd Qu.: 1.2M
Max.   : 5.4M

The good thing is, though, that the browser will cache them (for some period of time) so they aren’t re-downloaded every time you need them. Because of this, we’re going to ignore download time from consideration since they’re all, as we’ll see, below, yanked form cache in single-digit milliseconds.

When you call library(PACKAGE) R code gets executed, and that takes time. On modern desktops with local R installs, you almost never notice the time passage for this. This is not the case for WebR:

Screen capture of the ggplot2 package loading part of a Developer Tools waterfall chart.

Screen capture of the ggplot2 package loading part of a Developer Tools waterfall chart.

The Matrix, mgcv, and farver packages grind things to a halt. You felt that if you hit up the example at the beginning of the post. Brutal. Painful. Terrible.

This got me curious about all the other packages that are available to WebR (93 as of the date on this post).

Approaching R Package Load/library Benchmarking In A Browser

Much like the skewed package file size distribution of presently available R WASM packages, the per-package dependency distribution is also pretty skewed:

Min.   :  1
1st Qu.:  1
Median :  1
Mean   :  2
3rd Qu.:  2
Max.   : 15

This is good! It means you’re mostly safe to have fun with WebR and do not have to focus on working around an initial slowdown. Still, this did not deter me from a time sink.

I had to figure out a way to individually test the install/library of each WASM R packed independently, in a fresh WebR context.

One obvious way is to make 93 HTML files and load them all by hand.

O_O

There had to be a better way, and I immediately turned to “iframes” as a solution.

While I could have scripted the creation of proper for HTML 93 iframes to be put into a page, that’s not a great idea for a number of reasons:

  • that’ll crash every modern browser: far too many child iframes, all with their own DOM contexts sounds horrible
  • 93 “simultaneous” WebR initializations would consume all browser resources and DoS the tab
  • the “simultaneous” loading would skew timing results, even when the package files are cached

The solution was to use dynamically created iframes. One potential “gotcha” for this could have been the modern browser security model. Thanks to some dangerous hardware-level weakness that were discovered and exploited a few years back, Chrome and other browsers shored up the safety contracts between iframes and parent pages. Not doing so could have allowed attackers to have some fun at your expense.

If you’ve been following along the past week or so, to get the best performance with WebR, you need to make sure certain HTTP headers are in place so the browser can trust what you’re doing enough to relax some restrictions. Dynamically created iframes have no “headers”, per-se, but the clever folks who make browser bits for a living came up with a way to handle this. We just need to mark the frame as credentialless and we’ll get good performance (please read the link to get more context).

So, we can run a slightly expanded version of the (way) above javascript code to get timer stats, but how do we collect them?

Well, the parent of the iframe can talk to the iframe and vice-versa via postMessage(), so all we need to do is have the iframe send data back to the parent when it is done. This is also a signal we can kill the child iframe, freeing up resources, and then move on to the next one.

An Unexpected Twist

It turns out that some WASM-ified R packages are busted. Specifically:

  • fs
  • Hmisc
  • latticeExtra
  • pkgLoad

Some functions in each of them are needed by one or more other packages, but — as you’ll see if you run the benchmark site — they fail to library() after installation.

This was a “gotcha” I just had to wrap a try/catch block around, and also pass back information about.

Putting It All Together

You can run your own benchmarks at this playground page. View-source on the page to see the code (there’s just index.html and style.css). You can also see it at the WebR Experiments repo.

When the page loads, it fetches the last produced copy of https://rud.is/data/webr-packages.json. This is a JSON file I’m generating every night that contains all the packages available in “WASM notCRAN”. It just steals PACKAGES.rds every day and serializes it to JSON. Feel free to use it (if you get a CORS error lemme know; you shouldn’t but it’s an odd year).

Controls and sample output for the benchmark site.

The first thing your eyes will likely be drawn to is: “✅ Context is cross-origin isolated!”. When I was debugging early on WebR performance issues, George (the Godfather of WebR) noted that we needed certain headers to get those aforementioned safety restrictions loosened up a bit. You can test the global crossOriginIsolated variable to see if you’ve setup the headers correctly and read more about it when you have time. While it’s not needed on that page, I left it in so I could write this paragraph.

You’ll also see a “download results?” checkbox that is by default un-checked. If checked, you’ll get a JSON file with all the results in the table that is dynamically constructed.

After you tap “Begin Benchmark”, you can go get a matcha and come back.

You’ll see the results in a table and a surprise Observable Plot histogram (the post’s featured image).

I disable the controls after the run since you really should close the tab and start a fresh one (not just a reload) to get a clean context.

If you use the site and download the JSON, you can hit up this Observable notebook and put the JSON in a fork of it. I would also not mind it if you could post your JSON to the WebR Experiments repo as an issue and include the browser and system config you were using at the time.

FIN

This was a fun distraction, and shows you can use most of the presently available WebR packages without concern.

Make sure to check back for those WebR graphics posts!

Let’s walk through how to set up a ~minimal HTML/JS/CSS + WebR-powered “app” on a server you own. This will be vanilla JS (i.e. no React/Vue/npm/bundler) you can hack on at-will.

TL;DR: You can find the source to the app and track changes to it over on GitHub if you want to jump right in.

In the docs/ directory in the GH repo you’ll see an example of using this in GH Pages.Here it is live: https://hrbrmstr.github.io/webr-app/index.html. Info on what you need to do for that is below.

If all went well, you should see the output of a call to WebR right here (it may take a few seconds):

Getting Your Server Set Up

I’ll try to keep updating this with newer WebR releases. Current version is 0.1.0 and you can grab that from: https://github.com/r-wasm/webr/releases/download/v0.1.0/webr-0.1.0.tar.gz.

System-Wide WebR

You should read this section in the official WebR documentation before continuing.

I’m using a server-wide /webr directory on my rud.is domain so I can use it on any page I serve.

WebR performance will suffer if it can’t use SharedArrayBuffers. So, I have these headers enabled on my /webr directory:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

I use nginx, so that looks like:

location ^~ /webr {
  add_header "Cross-Origin-Opener-Policy" "same-origin";
  add_header "Cross-Origin-Embedder-Policy" "require-corp";
}

YMMV.

For good measure (and in case I move things around), I stick those headers on my any app dir that will use WebR. I don’t use them server-wide, though.

And They Call It a MIME. A MIME!

WebR is a JavaScript module, and you need to make sure that files with an mjs extension have a MIME type of text/javascript, or some browsers won’t be happy.

A typical way for webservers to know how to communicate this is via a mime.types file. That is not true for all webservers, and I’ll add steps for ones that use a different way to configure this. The entry should look like this:

text/javascript  mjs;

Testing The WebR Set Up

You should be able to hit that path on your webserver in your browser and see the WebR console app. If you do, you can continue. If not, leave an issue and I can try to help you debug it, but that’s on a best-effort basis for me.

Installing The App

We’ll dig into the app in a bit, but you probably want to see it working, so let’s install this ~minimal app.

My personal demo app is anchored off of /webr-app on my rud.is web server. Here’s how to replicate it:

# Go someplace safe
$ cd $TMPDIR

# Get the app bundle
# You can also use the GH release version, just delete the README after installing it.
$ curl -o webr-app.tgz https://rud.is/dl/webr-app.tgz

# Expand it
$ tar -xvzf webr-app.tgz
x ./webr-app/
x ./webr-app/modules/
x ./webr-app/modules/webr-app.js
x ./webr-app/modules/webr-helpers.js
x ./webr-app/css/
x ./webr-app/css/simple.min.css
x ./webr-app/css/app.css
x ./webr-app/main.js
x ./webr-app/index.html

# 🚨 GO THROUGH EACH FILE
# 🚨 to make sure I'm not pwning you!
# 🚨 Don't trust anything or anyone.

# Go to the webserver root
$ cd $PATH_TO_WEBSERVER_DOC_ROOT_PATH

# Move the directory
$ mv $TMPDIR/webr-app .

# Delete the tarball (optional)
$ rm $TMPDIR/webr-app.tgz

Hit up that path on your web server and you should see what you saw on mine.

WebR-Powered App Structure

.
├── css                  # CSS (obvsly)
│   ├── app.css          # app-specific ones
│   └── simple.min.css   # more on this in a bit
├── index.html           # The main app page
├── main.js              # The main app JS
└── modules              # We use ES6 JS modules
    ├── webr-app.js      # Main app module
    └── webr-helpers.js  # Some WebR JS Helpers I wrote

Simple CSS

If you sub to my newsletter, you know I play with tons of tools and frameworks. Please use what you prefer.For folks who don’t normally do this type of stuff, I included a copy of Simple CSS b/c, well, it is simple to use. Please use this resource to get familiar with it if you do continue to use it.

JavaScript Modules

When I’m in “hack” mode (like I was for the first few days after WebR’s launch), I revert to old, bad habits. We will not replicate those here.

We’re using JavaScript Modules as the project structure. We aren’t “bundling” (slurping up all app support files into a single, minified file) since not every R person is a JS tooling expert. We’re also not using them as they really aren’t needed, and I like to keep things simple and as dependency-free as possible.

In index.html you’ll see this line:

<script type="module" src="./main.js"></script> 

This tells the browser to load that JS file as if it were a module. As you read (you did read the MDN link, above, right?), modules give us locally-scoped names/objects/features and protection from clobbering imported names.

Our main module contains all the crunchy goodness core functionality of our app, which does nothing more than:

  • loads WebR
  • Tells you how fast it loaded + instantiated
  • Yanks mtcars from the instantiated R session (mtcars was the third “thing” I typed into R, ever, so my brain defaults to it).
  • Makes an HTML table from it using D3.

It’s small enough to include here:

import { format } from "https://cdn.skypack.dev/d3-format@3";
import * as HelpR from './modules/webr-helpers.js'; // WebR-specific helpers
// import * as App from './modules/webr-app.js'; // our app's functions, if it had some

console.time('Execution Time'); // keeps on tickin'
const timerStart = performance.now();

import { WebR } from '/webr/webr.mjs'; // service workers == full path starting with /

globalThis.webR = new WebR({
    WEBR_URL: "/webr/", # our system-wide WebR
    SW_URL: "/webr/"    # what ^^ said
}); 
await globalThis.webR.init(); 

// WebR is ready to use. So, brag about it!

const timerEnd = performance.now();
console.timeEnd('Execution Time');

document.getElementById('loading').innerText = `WebR Loaded! (${format(",.2r")((timerEnd - timerStart) / 1000)} seconds)`;

const mtcars = await HelpR.getDataFrame(globalThis.webR, "mtcars");
console.table(mtcars);
HelpR.simpleDataFrameTable("#tbl", mtcars);

globalThis is a special JS object that lets you shove stuff into the global JS environment. Not 100% needed, but if you want to use the same WebR context in in other app module blocks, this is how you’d do it.

Let’s focus on the last three lines.

const mtcars = await HelpR.getDataFrame(globalThis.webR, "mtcars");

This uses a helper function I made to get a data frame object from R in a way more compatible for most JS and JS libraries than the default JS object WebR’s toJs() function converts all R objects to.

console.table(mtcars);

This makes a nice table in the browser’s Developer Tools console. I did this so I could have you open up the console to see it, but I also want you to inspect the contents of the object (just type mtcars and hit enter/return) to see this nice format.

We pass in a WebR context we know will work, and then any R code that will evaluate and return a data frame. It is all on you (for the moment) to ensure the code runs and that it returns a data frame.

The last line:

HelpR.simpleDataFrameTable("#tbl", mtcars);

calls another helper function to make the table.

HelpR

I may eventually blather eloquently and completely about what’s in modules/webr-helpers.js. For now, let me focus on just a couple things, especially since it’s got some sweet JSDoc comments.

First off, let’s talk more about those comments.

I use VS Code for ~60% of my daily ops, and used it for this project. If you open up the project root in VS Code and select/hover over simpleDataFrameTable in that last line, you’ll get some sweet lookin’formatted help. VS Code is wired up for this (other editors/IDEs are too), so I encourage you to make liberal use of JSDoc comments in your own functions/modules.

Now, let’s peek behind the curtain of getDataFrame:

export async function getDataFrame(ctx, rEvalCode) {
    let result = await ctx.evalR(`${rEvalCode}`);
    let output = await result.toJs();
    return (Promise.resolve(webRDataFrameToJS(output)));
}

The export tells the JS environment that that function is available if imported properly. Without the export the function is local to the module.

let result = await ctx.evalR(`${rEvalCode}`);

A proper app would use JS try/catch potential errors. There’s an example of that in the fancy React app code over at WebR’s site. We just throw caution to the wind and evaluate whatever we’re given. In theory, we should have R ensure it’s a data frame which we kind of can’t do on the JS side since the next line:

let output = await result.toJs();

will show the type as a list (b/c data.frames are lists).

I’ll likely add some more helpers to a more standalone helper module, but I suspect that corporate R will beat me to that, so I will likely also not invest too much time on it, at least externally.

Await! Await! Do Tell Me (about await)!

Before we can talk about the last line:

return (Promise.resolve(webRDataFrameToJS(output)));

let’s briefly talk about async ops in JS.

The JavaScript environment in your browser is single-threaded. async-hronous ops let pass of code to threads to avoid blocking page operations. These get executed “whenever”, so all you get is a vapid and shallow promise to of code execution and potentially giving you something back.

We explicitly use await for when we really need the code to run and, in this case, give us something back. We can keep chaining async function calls, but — if we need to make sure the code runs and/or we get data back — we will eventually need to keep our promise to do so; hence, Promise.resolve.

Serving WebR From GitHub Pages

The docs/ directory in the repo shows a working version on GH pages.

main.js needs a few tweaks:

// This will use Posit's CDN

import('https://webr.r-wasm.org/latest/webr.mjs').then( // this wraps the main app code
    async ({ WebR }) => {

        globalThis.webR = new WebR({
            SW_URL: "/webr-app/"            // 👈🏼 needs to be your GHP main path
        });
        await globalThis.webR.init();

        const timerEnd = performance.now();
        console.timeEnd('Execution Time');

        document.getElementById('loading').innerText = `WebR Loaded! (${format(",.2r")((timerEnd - timerStart) / 1000)} seconds)`;

        const mtcars = await HelpR.getDataFrame(globalThis.webR, "mtcars");
        console.table(mtcars);
        HelpR.simpleDataFrameTable("#tbl", mtcars);

  }
);

Moar To Come

Please hit up this terribly coded dashboard app to see some fancier use. I’ll be converting that to modules and expanding git a bit.

WebR 0.1.0 was released! I had been git-stalking George (the absolute genius who we all must thank for this) for a while and noticed the GH org and repos being updated earlier this week, So, I was already pretty excited.

It dropped today, and you can hit that link for all the details and other links.

I threw together a small demo to show how to get it up and running without worrying about fancy “npm projects” and the like.

View-source on that link, or look below for a very small (so, hopefully accessible) example of how to start working with WASM-ified R in a web context.

UPDATE:

Four more links:


<html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>WebR Super Simple Demo</title> <link rel="stylesheet" href="/style.css" type="text/css"> <style> li { font-family:monospace; } .nospace { margin-bottom: 2px; } </style> </head> <body> <div id="main"> <p>Simple demo to show the basics of calling the new WebR WASM!!!!</p> <p><code>view-source</code> to see how the sausage is made</code></p> <p class="nospace">Input a number, press "Call R" (when it is enabled) and magic will happen.</p> <!-- We'll pull the value from here --> <input type="text" id="x" value="10"> <!-- This button is disabled until WebR is loaded --> <button disabled="" id="callr">Call R</button> <!-- Output goes here --> <div id="output"></div> <!-- WebR is a module so you have to do this. --> <!-- NOTE: Many browsers will not like it if `.mjs` files are served --> <!-- with a content-type that isn't text/javascript --> <!-- Try renaming it from .mjs to .js if you hit that snag. --> <script type="module"> // https://github.com/r-wasm/webr/releases/download/v0.1.0/webr-0.1.0.tar.gz // // I was lazy and just left it in one directory import { WebR } from '/webr-d3-demo/webr.mjs'; // service workers == full path starting with / const webR = new WebR(); // get ready to Rumble await webR.init(); // shot's fired console.log("WebR"); // just for me b/c I don't trust anything anymore // we call this function on the button press async function callR() { let x = document.getElementById('x').value.trim(); // get the value we input; be better than me and do validation console.log(`x = ${x}`) // as noted, i don't trust anything let result = await webR.evalR(`rnorm(${x},5,1)`); // call some R! let output = await result.toArray(); // make the result something JS can work with document.getElementById("output").replaceChildren() // clear out the <div> (this is ugly; be better than me) // d3 ops d3.select("#output").append("ul") const ul = d3.select("ul") ul.selectAll("li") .data(output) .enter() .append("li") .text(d => d) } // by the time we get here, WebR is ready, so we tell the button what to do and re-enable the button document.getElementById('callr').onclick = callR; document.getElementById('callr').disabled = false; </script> <!-- d/l from D3 site or here if you trust me --> <script src="d3.min.js"></script> </div> </body> </html>

Ref: https://rud.is/b/2022/12/19/2022-hanukkah-of-data-puzzle-1/

library(tidyverse)

cust <- read_csv("~/Downloads/noahs-csv/noahs-customers.csv")
orders_items <- read_csv("~/Downloads/noahs-csv/noahs-orders_items.csv")
orders <- read_csv("~/Downloads/noahs-csv/noahs-orders.csv")
products <- read_csv("~/Downloads/noahs-csv/noahs-products.csv")

orders_items |> 
  left_join(products) -> oip

orders |> 
  left_join(oip) -> orders

orders |> 
  filter(
    2017 == lubridate::year(ordered),
    grepl("cleaner|bagel", desc, ignore.case=TRUE)
  ) |> 
  group_by(customerid, orderid) |> 
  summarise(
    ord = paste0(desc, collapse="; "),
    n = n()
  ) |> 
  arrange(desc(n)) # look for bagel + rug cleaner

cust |> 
  filter(customerid == '####') |> 
  select(phone)

Visiting #2 and doing some $WORK-work, but intrigued with Hanukkah of Data since Puzzle 0 was solvable with a ZIP password cracker (the calendar date math seemed too trivial to bother with).

Decided to fall back to R for this (vs Observable for the Advent of Code which I’ll dedicate time to finishing next week).

R has a {phonenumber} package, so we’ll cheat and use that despite it being very brutish in how it does the letterToNumber() conversion.

No spoilers besides the code.

library(phonenumber)
library(tidyverse)

cust <- read_csv("~/Downloads/noahs-csv/noahs-customers.csv")

cust |> 
  filter(!grepl("[01]", phone)) |> # only care abt letters
  mutate(
    last_name = stri_replace_all_regex(name, "(II|III|IV|Jr\\.)", ""), # get rid of suffix if any
  ) |> 
  separate( # get only the last name
    col = last_name,
    into = c("x1", "x2", "last_name"),
    sep = " ",
    fill = "left"
  ) |> 
  filter(
    nchar(last_name) == 10 # only complete last names
  ) |> 
  mutate(
    last_name = toupper(last_name),
    phone = gsub("-", "", phone) # we're going to compare so remove the '-'
  ) |> 
  select(last_name, phone) |> 
  mutate(
    trans = strsplit(xx$last_name, "") |> 
      map_chr(~map(.x, letterToNumber) |> paste0(collapse="")) # feels like I cld optimize this
  ) |> 
  filter(trans == phone)

Quarto is amazing! And, it’s eating the world! OK. Perhaps not the entire world. But it’s still amazing!

If you browse around the HQ, you’ll find many interesting notebooks. You may even have a few yourself! Wouldn’t it be great if you could just import an Observable notebook right into Quarto? Well, now you can.

#' Transform an Observable Notebook into a Quarto project
#' 
#' This will yank the cells from a live Observable notebook and turn it into a Quarto project,
#' downloading all the `FileAttachments` as well.
#' 
#' @param ohq_ref either a short ref (e.g. `@@hrbrmstr/just-one-more-thing`) or a full
#'     URL to a published Observable notebook
#' @param output_dir quarto project directory (will be created if not already present)
#' @param quarto_filename if `NULL` (the default) the name will be the slug (e.g. `just-one-more-thing`
#'     as in the `ohq_ref` param eample) with `.qmd` suffix
#' @param echo set `echo` to `true` or `false` in the YAML
ohq_to_quarto <- function(ohq_ref, output_dir, quarto_filename = NULL, echo = FALSE) {

  ohq_ref <- ohq_ref[1]
  if (grepl("^@", ohq_ref)) ohq_ref <- sprintf("https://observablehq.com/%s", ohq_ref)

  output_dir <- output_dir[1]
  if (!dir.exists(output_dir)) dir.create(output_dir)

  quarto_filename <- quarto_filename[1]

  pg <- rvest::read_html(ohq_ref)

  pg |> 
    html_nodes("script#__NEXT_DATA__") |> 
    html_text() |> 
    jsonlite::fromJSON() -> x

  meta <- x$props$pageProps$initialNotebook
  nodes <- x$props$pageProps$initialNotebook$nodes

  if (is.null(quarto_filename)) quarto_filename <- sprintf("%.qmd", meta$slug)

  c(
    "---", 
    sprintf("title: '%s'", meta$title), 
    "format: html", 
    if (echo) "echo: true" else "echo: false",
    "---",
    "",
    purrr::map2(nodes$value, nodes$mode, ~{
      c(

        "```{ojs}",
        dplyr::case_when(
          .y == "md" ~ sprintf("md`%s`", .x),
          .y == "html" ~ sprintf("html`%s`", .x),
          TRUE ~ .x
        ),
        "```",
        ""
      )

    })
  ) |> 
    purrr::flatten_chr() |> 
    cat(
      file = file.path(output_dir, quarto_filename), 
      sep = "\n"
    )

  if (length(meta$files)) {
    if (nrow(meta$files) > 0) {
      purrr::walk2(
        meta$files$download_url,
        meta$files$name, ~{
          download.file(
            url = .x,
            destfile = file.path(output_dir, .y),
            quiet = TRUE
          )
        }
      )
    }
  }

}

You can try that out with my Columbo notebook:

ohq_to_quarto(
  ohq_ref = "@hrbrmstr/just-one-more-thing", 
  output_dir = "~/Development/columbo",
  quarto_filename = "columbo.qmd",
  echo = FALSE
)

That will download the CSV file into the specified directory and convert the cells to a .qmd. You can download that example file, but you’ll need the data to run it (or just run the converter).

This is what the directory tree looks like after the script is run and the document is rendered:

columbo/
├── columbo.html
├── columbo.qmd
├── columbo_data.csv
└── columbo_files
    └── libs
        ├── bootstrap
        │   ├── bootstrap-icons.css
        │   ├── bootstrap-icons.woff
        │   ├── bootstrap.min.css
        │   └── bootstrap.min.js
        ├── clipboard
        │   └── clipboard.min.js
        ├── quarto-html
        │   ├── anchor.min.js
        │   ├── popper.min.js
        │   ├── quarto-syntax-highlighting.css
        │   ├── quarto.js
        │   ├── tippy.css
        │   └── tippy.umd.min.js
        └── quarto-ojs
            ├── quarto-ojs-runtime.js
            └── quarto-ojs.css

The function has not been battle tested, and it’s limited to the current functionality, but it should do what it says on the tin.

I’ll turn this into a Rust binary so it’s more usable outside of the R ecosystem.

You can try out a fledgling Rust version here.

My {darksky} package has been around for years, now, and the service that powers it was purchased by Apple before the pandemic. The DarkSky API is slated to be shuttered in December of this year and is being replaced by Apple’s WeatherKit xOS Framework and REST API.

I’ve started work on a {weatherkit} package which uses the WeatherKit REST API. You’ll need an Apple Developer account and will also need to setup some items in said account, and locally so you can authenticate to the API. Once all the authentication bits are setup, it’s pretty easy to get the weather data:

wx <- wxkit_weather(43.2683199, -70.8635506)
wx <- wx_tidy(wx)
str(wx)
## List of 4
##  $ currentWeather  :List of 18
##   ..$ name                  : chr "CurrentWeather"
##   ..$ metadata              :List of 8
##   .. ..$ attributionURL: chr "https://weather-data.apple.com/legal-attribution.html"
##   .. ..$ expireTime    : POSIXct[1:1], format: "2022-07-07 12:28:07"
##   .. ..$ latitude      : num 43.3
##   .. ..$ longitude     : num -70.9
##   .. ..$ readTime      : POSIXct[1:1], format: "2022-07-07 12:23:07"
##   .. ..$ reportedTime  : POSIXct[1:1], format: "2022-07-07 10:48:55"
##   .. ..$ units         : chr "m"
##   .. ..$ version       : int 1
##   ..$ asOf                  : POSIXct[1:1], format: "2022-07-07 12:23:07"
##   ..$ cloudCover            : num 0.29
##   ..$ conditionCode         : chr "MostlyClear"
##   ..$ daylight              : logi TRUE
##   ..$ humidity              : num 0.68
##   ..$ precipitationIntensity: num 0
##   ..$ pressure              : num 1018
##   ..$ pressureTrend         : chr "rising"
##   ..$ temperature           : num 19.5
##   ..$ temperatureApparent   : num 19.4
##   ..$ temperatureDewPoint   : num 13.5
##   ..$ uvIndex               : int 2
##   ..$ visibility            : num 29413
##   ..$ windDirection         : int 50
##   ..$ windGust              : num 12
##   ..$ windSpeed             : num 4.42
##  $ forecastDaily   :List of 3
##   ..$ name    : chr "DailyForecast"
##   ..$ metadata:List of 8
##   .. ..$ attributionURL: chr "https://weather-data.apple.com/legal-attribution.html"
##   .. ..$ expireTime    : POSIXct[1:1], format: "2022-07-07 13:23:07"
##   .. ..$ latitude      : num 43.3
##   .. ..$ longitude     : num -70.9
##   .. ..$ readTime      : POSIXct[1:1], format: "2022-07-07 12:23:07"
##   .. ..$ reportedTime  : POSIXct[1:1], format: "2022-07-07 10:48:55"
##   .. ..$ units         : chr "m"
##   .. ..$ version       : int 1
##   ..$ days    :'data.frame': 10 obs. of  26 variables:
##   .. ..$ forecastStart      : POSIXct[1:10], format: "2022-07-07 04:00:00" "2022-07-08 04:00:00" "2022-07-09 04:00:00" "2022-07-10 04:00:00" ...
##   .. ..$ forecastEnd        : POSIXct[1:10], format: "2022-07-08 04:00:00" "2022-07-09 04:00:00" "2022-07-10 04:00:00" "2022-07-11 04:00:00" ...
##   .. ..$ conditionCode      : chr [1:10] "PartlyCloudy" "PartlyCloudy" "MostlyClear" "MostlyClear" ...
##   .. ..$ maxUvIndex         : int [1:10] 7 7 7 8 7 6 7 4 5 4
##   .. ..$ moonPhase          : chr [1:10] "firstQuarter" "firstQuarter" "waxingGibbous" "waxingGibbous" ...
##   .. ..$ moonrise           : POSIXct[1:10], format: "2022-07-07 17:38:12" "2022-07-08 18:50:47" "2022-07-09 20:07:35" "2022-07-10 21:27:35" ...
##   .. ..$ moonset            : POSIXct[1:10], format: "2022-07-07 04:32:48" "2022-07-08 04:54:51" "2022-07-09 05:20:27" "2022-07-10 05:51:50" ...
##   .. ..$ precipitationAmount: num [1:10] 0 0.49 0 0 0 1.32 0.24 3.44 5.07 8.35
##   .. ..$ precipitationChance: num [1:10] 0.01 0.15 0.07 0 0.07 0.39 0.37 0.4 0.47 0.44
##   .. ..$ precipitationType  : chr [1:10] "clear" "rain" "clear" "clear" ...
##   .. ..$ snowfallAmount     : num [1:10] 0 0 0 0 0 0 0 0 0 0
##   .. ..$ solarMidnight      : POSIXct[1:10], format: "2022-07-07 04:48:29" "2022-07-08 04:48:39" "2022-07-09 04:48:49" "2022-07-10 04:48:58" ...
##   .. ..$ solarNoon          : POSIXct[1:10], format: "2022-07-07 16:48:26" "2022-07-08 16:48:35" "2022-07-09 16:48:44" "2022-07-10 16:48:53" ...
##   .. ..$ sunrise            : POSIXct[1:10], format: "2022-07-07 09:10:59" "2022-07-08 09:11:42" "2022-07-09 09:12:26" "2022-07-10 09:13:11" ...
##   .. ..$ sunriseCivil       : POSIXct[1:10], format: "2022-07-07 08:36:06" "2022-07-08 08:36:53" "2022-07-09 08:37:41" "2022-07-10 08:38:31" ...
##   .. ..$ sunriseNautical    : POSIXct[1:10], format: "2022-07-07 07:50:45" "2022-07-08 07:51:39" "2022-07-09 07:52:36" "2022-07-10 07:53:34" ...
##   .. ..$ sunriseAstronomical: POSIXct[1:10], format: "2022-07-07 06:55:17" "2022-07-08 06:56:30" "2022-07-09 06:57:46" "2022-07-10 06:59:04" ...
##   .. ..$ sunset             : POSIXct[1:10], format: "2022-07-08 00:25:50" "2022-07-09 00:25:26" "2022-07-10 00:24:59" "2022-07-11 00:24:30" ...
##   .. ..$ sunsetCivil        : POSIXct[1:10], format: "2022-07-08 01:00:39" "2022-07-09 01:00:10" "2022-07-10 00:59:38" "2022-07-11 00:59:04" ...
##   .. ..$ sunsetNautical     : POSIXct[1:10], format: "2022-07-08 01:46:01" "2022-07-09 01:45:23" "2022-07-10 01:44:42" "2022-07-11 01:43:58" ...
##   .. ..$ sunsetAstronomical : POSIXct[1:10], format: "2022-07-08 02:41:14" "2022-07-09 02:40:16" "2022-07-10 02:39:14" "2022-07-11 02:38:09" ...
##   .. ..$ temperatureMax     : num [1:10] 25.8 28.7 24.9 25.4 28.9 ...
##   .. ..$ temperatureMin     : num [1:10] 13.7 16.3 14.8 12.2 12.4 ...
##   .. ..$ daytimeForecast    :'data.frame':   10 obs. of  11 variables:
##   .. .. ..$ forecastStart      : POSIXct[1:10], format: "2022-07-07 11:00:00" "2022-07-08 11:00:00" "2022-07-09 11:00:00" "2022-07-10 11:00:00" ...
##   .. .. ..$ forecastEnd        : POSIXct[1:10], format: "2022-07-07 23:00:00" "2022-07-08 23:00:00" "2022-07-09 23:00:00" "2022-07-10 23:00:00" ...
##   .. .. ..$ cloudCover         : num [1:10] 0.39 0.45 0.33 0.11 0.42 0.69 0.39 0.95 0.87 0.88
##   .. .. ..$ conditionCode      : chr [1:10] "PartlyCloudy" "PartlyCloudy" "MostlyClear" "Clear" ...
##   .. .. ..$ humidity           : num [1:10] 0.57 0.58 0.54 0.47 0.49 0.63 0.64 0.71 0.7 0.66
##   .. .. ..$ precipitationAmount: num [1:10] 0 0.31 0 0 0 0.26 0.17 3.15 0.22 1.37
##   .. .. ..$ precipitationChance: num [1:10] 0 0.09 0.04 0 0.02 0.29 0.16 0.31 0.33 0.3
##   .. .. ..$ precipitationType  : chr [1:10] "clear" "rain" "clear" "clear" ...
##   .. .. ..$ snowfallAmount     : num [1:10] 0 0 0 0 0 0 0 0 0 0
##   .. .. ..$ windDirection      : int [1:10] 155 263 122 237 231 228 219 98 39 62
##   .. .. ..$ windSpeed          : num [1:10] 8.73 9.42 7.42 6.23 9.75 ...
##   .. ..$ overnightForecast  :'data.frame':   10 obs. of  11 variables:
##   .. .. ..$ forecastStart      : POSIXct[1:10], format: "2022-07-07 23:00:00" "2022-07-08 23:00:00" "2022-07-09 23:00:00" "2022-07-10 23:00:00" ...
##   .. .. ..$ forecastEnd        : POSIXct[1:10], format: "2022-07-08 11:00:00" "2022-07-09 11:00:00" "2022-07-10 11:00:00" "2022-07-11 11:00:00" ...
##   .. .. ..$ cloudCover         : num [1:10] 0.49 0.5 0.15 0.37 0.46 0.4 0.88 0.91 0.8 NA
##   .. .. ..$ conditionCode      : chr [1:10] "PartlyCloudy" "PartlyCloudy" "MostlyClear" "MostlyClear" ...
##   .. .. ..$ humidity           : num [1:10] 0.78 0.78 0.71 0.73 0.69 0.81 0.83 0.85 0.84 NA
##   .. .. ..$ precipitationAmount: num [1:10] 0.06 0.11 0 0 0 1.11 0.04 2.26 6.47 NA
##   .. .. ..$ precipitationChance: num [1:10] 0.05 0.07 0.01 0.02 0.1 0.27 0.24 0.31 0.31 NA
##   .. .. ..$ precipitationType  : chr [1:10] "rain" "rain" "clear" "clear" ...
##   .. .. ..$ snowfallAmount     : num [1:10] 0 0 0 0 0 0 0 0 0 NA
##   .. .. ..$ windDirection      : int [1:10] 192 341 347 223 218 242 276 13 49 NA
##   .. .. ..$ windSpeed          : num [1:10] 9.92 7.15 6.59 5.52 10.95 ...
##   .. ..$ restOfDayForecast  :'data.frame':   10 obs. of  11 variables:
##   .. .. ..$ forecastStart      : POSIXct[1:10], format: "2022-07-07 12:23:07" NA NA NA ...
##   .. .. ..$ forecastEnd        : POSIXct[1:10], format: "2022-07-08 04:00:00" NA NA NA ...
##   .. .. ..$ cloudCover         : num [1:10] 0.47 NA NA NA NA NA NA NA NA NA
##   .. .. ..$ conditionCode      : chr [1:10] "PartlyCloudy" NA NA NA ...
##   .. .. ..$ humidity           : num [1:10] 0.6 NA NA NA NA NA NA NA NA NA
##   .. .. ..$ precipitationAmount: num [1:10] 0 NA NA NA NA NA NA NA NA NA
##   .. .. ..$ precipitationChance: num [1:10] 0.01 NA NA NA NA NA NA NA NA NA
##   .. .. ..$ precipitationType  : chr [1:10] "clear" NA NA NA ...
##   .. .. ..$ snowfallAmount     : num [1:10] 0 NA NA NA NA NA NA NA NA NA
##   .. .. ..$ windDirection      : int [1:10] 163 NA NA NA NA NA NA NA NA NA
##   .. .. ..$ windSpeed          : num [1:10] 9.7 NA NA NA NA NA NA NA NA NA
##  $ forecastHourly  :List of 3
##   ..$ name    : chr "HourlyForecast"
##   ..$ metadata:List of 8
##   .. ..$ attributionURL: chr "https://weather-data.apple.com/legal-attribution.html"
##   .. ..$ expireTime    : POSIXct[1:1], format: "2022-07-07 13:23:07"
##   .. ..$ latitude      : num 43.3
##   .. ..$ longitude     : num -70.9
##   .. ..$ readTime      : POSIXct[1:1], format: "2022-07-07 12:23:07"
##   .. ..$ reportedTime  : POSIXct[1:1], format: "2022-07-07 10:48:55"
##   .. ..$ units         : chr "m"
##   .. ..$ version       : int 1
##   ..$ hours   :'data.frame': 243 obs. of  20 variables:
##   .. ..$ forecastStart         : POSIXct[1:243], format: "2022-07-07 02:00:00" "2022-07-07 03:00:00" "2022-07-07 04:00:00" "2022-07-07 05:00:00" ...
##   .. ..$ cloudCover            : num [1:243] 0.02 0.01 0.02 0.31 0.44 0.74 0.3 1 0.96 0.32 ...
##   .. ..$ conditionCode         : chr [1:243] "Clear" "Clear" "Clear" "MostlyClear" ...
##   .. ..$ daylight              : logi [1:243] FALSE FALSE FALSE FALSE FALSE FALSE ...
##   .. ..$ humidity              : num [1:243] 0.74 0.78 0.81 0.83 0.86 0.88 0.9 0.92 0.88 0.83 ...
##   .. ..$ precipitationAmount   : num [1:243] 0 0 0 0 0 0 0 0 0 0 ...
##   .. ..$ precipitationIntensity: num [1:243] 0 0 0 0 0 0 0 0 0 0 ...
##   .. ..$ precipitationChance   : num [1:243] 0 0 0 0 0 0 0 0 0 0 ...
##   .. ..$ precipitationType     : chr [1:243] "clear" "clear" "clear" "clear" ...
##   .. ..$ pressure              : num [1:243] 1014 1015 1016 1016 1016 ...
##   .. ..$ pressureTrend         : chr [1:243] "rising" "rising" "rising" "rising" ...
##   .. ..$ snowfallIntensity     : num [1:243] 0 0 0 0 0 0 0 0 0 0 ...
##   .. ..$ temperature           : num [1:243] 18.3 17 16.3 15.7 14.9 ...
##   .. ..$ temperatureApparent   : num [1:243] 18.2 16.9 16.1 15.5 14.7 ...
##   .. ..$ temperatureDewPoint   : num [1:243] 13.6 13.1 12.9 12.8 12.6 ...
##   .. ..$ uvIndex               : int [1:243] 0 0 0 0 0 0 0 0 0 1 ...
##   .. ..$ visibility            : num [1:243] 28105 26514 24730 23883 23669 ...
##   .. ..$ windDirection         : int [1:243] 315 302 315 308 310 298 307 316 319 6 ...
##   .. ..$ windGust              : num [1:243] 2.93 2.56 2.92 3.25 3.35 ...
##   .. ..$ windSpeed             : num [1:243] 2.93 2.56 2.92 3.25 3.35 3.09 3.51 2.91 2.36 4.55 ...
##  $ forecastNextHour:List of 6
##   ..$ name         : chr "NextHourForecast"
##   ..$ metadata     :List of 9
##   .. ..$ attributionURL: chr "https://weather-data.apple.com/legal-attribution.html"
##   .. ..$ expireTime    : POSIXct[1:1], format: "2022-07-07 13:23:07"
##   .. ..$ language      : chr "en-US"
##   .. ..$ latitude      : num 43.3
##   .. ..$ longitude     : num -70.9
##   .. ..$ providerName  : chr "US National Weather Service"
##   .. ..$ readTime      : POSIXct[1:1], format: "2022-07-07 12:23:07"
##   .. ..$ units         : chr "m"
##   .. ..$ version       : int 1
##   ..$ summary      :'data.frame':    1 obs. of  4 variables:
##   .. ..$ startTime             : POSIXct[1:1], format: "2022-07-07 12:24:00"
##   .. ..$ condition             : chr "clear"
##   .. ..$ precipitationChance   : num 0
##   .. ..$ precipitationIntensity: num 0
##   ..$ forecastStart: POSIXct[1:1], format: "2022-07-07 12:24:00"
##   ..$ forecastEnd  : POSIXct[1:1], format: "2022-07-07 13:45:00"
##   ..$ minutes      :'data.frame':    81 obs. of  3 variables:
##   .. ..$ startTime             : POSIXct[1:81], format: "2022-07-07 12:24:00" "2022-07-07 12:25:00" "2022-07-07 12:26:00" "2022-07-07 12:27:00" ...
##   .. ..$ precipitationChance   : num [1:81] 0 0 0 0 0 0 0 0 0 0 ...
##   .. ..$ precipitationIntensity: num [1:81] 0 0 0 0 0 0 0 0 0 0 ...

The wx_tidy() function, for now, only does date-time string conversion to POSIXct objects, but it may do more in the future.

It doesn’t appear that historical weather data is available, yet, so you’re limited to using the API to get daily and hourly conditions and forecasts for the present plus some days. As such, I’ve focused a bit on some helper functions to show current conditions and forecasts in the R console/stdout:

current_conditions(wx)
##  Weather for (43.268, -70.864) as of 2022-07-07 08:23:07
## 
##  Conditions: Mostly Clear
## Temperature: 67.08°F
##  Feels like: 66.92°F
##   Dew Point: 56.28°F
##        Wind: 2.7 mph (NE)
##    Pressure: 1017.68 mb (rising)
##  Visibility: 18 miles
##    UV Index: 🟩 2 (Low)
## 
## https://weather-data.apple.com/legal-attribution.html
hourly_forecast(wx)
##  Weather forecast for (43.268, -70.864) as of 2022-07-07 08:23:07
## 
## Today @ 09:00 │ 🌡 69°F │ 💦 63% │ 1018 mb — │ 😎 │ Mostly Clear  │ 🟨
##       @ 10:00 │ 🌡 71°F │ 💦 58% │ 1018 mb — │ 😎 │ Mostly Clear  │ 🟨
##       @ 11:00 │ 🌡 74°F │ 💦 55% │ 1018 mb — │ 😎 │ Mostly Clear  │ 🟧
##       @ 12:00 │ 🌡 75°F │ 💦 53% │ 1017 mb — │ 😎 │ Partly Cloudy │ 🟧
##       @ 13:00 │ 🌡 77°F │ 💦 51% │ 1017 mb ↓ │ 😎 │ Partly Cloudy │ 🟧
##       @ 14:00 │ 🌡 78°F │ 💦 50% │ 1016 mb ↓ │ 😎 │ Partly Cloudy │ 🟧
##       @ 15:00 │ 🌡 78°F │ 💦 50% │ 1016 mb ↓ │ 😎 │ Partly Cloudy │ 🟨
##       @ 16:00 │ 🌡 77°F │ 💦 52% │ 1016 mb ↓ │ 😎 │ Partly Cloudy │ 🟨
##       @ 17:00 │ 🌡 76°F │ 💦 55% │ 1015 mb ↓ │ 😎 │ Partly Cloudy │ 🟩
##       @ 18:00 │ 🌡 75°F │ 💦 58% │ 1015 mb — │ 😎 │ Partly Cloudy │ 🟩
##       @ 19:00 │ 🌡 73°F │ 💦 62% │ 1015 mb — │ 😎 │ Mostly Clear  │ 🟩
##       @ 20:00 │ 🌡 70°F │ 💦 67% │ 1015 mb — │ 😎 │ Partly Cloudy │ 🟩
##       @ 21:00 │ 🌡 68°F │ 💦 71% │ 1015 mb — │ 🌕 │ Mostly Cloudy │ 🟩
##       @ 22:00 │ 🌡 67°F │ 💦 74% │ 1015 mb — │ 🌕 │ Mostly Cloudy │ 🟩
##       @ 23:00 │ 🌡 67°F │ 💦 74% │ 1015 mb — │ 🌕 │ Mostly Cloudy │ 🟩
##   Fri @ 00:00 │ 🌡 66°F │ 💦 74% │ 1015 mb — │ 🌕 │ Partly Cloudy │ 🟩
##       @ 01:00 │ 🌡 65°F │ 💦 78% │ 1015 mb — │ 🌕 │ Partly Cloudy │ 🟩
##       @ 02:00 │ 🌡 64°F │ 💦 81% │ 1015 mb — │ 🌕 │ Mostly Clear  │ 🟩
##       @ 03:00 │ 🌡 63°F │ 💦 83% │ 1015 mb — │ 🌕 │ Partly Cloudy │ 🟩
##       @ 04:00 │ 🌡 62°F │ 💦 85% │ 1015 mb — │ 🌕 │ Partly Cloudy │ 🟩
## 
## https://weather-data.apple.com/legal-attribution.html

Note that the attribution is required by Apple.

I’ll likely add a daily forecast console printer soon.

There are a few helper functions in the package for value conversion between unit systems, iconifying some values, and working with time zones. {weatherkit} uses lutz::tz_lookup_coords() in places to auto-determine the time zone from lat/lng pairs, and also includes a function to intuit lat/lng from an IP address using ipapi (they have a generous free tier).

As of the timestamp on this blog post, Apple’s WeatherKit provides up to 500,000 API calls a month per Apple Developer Program membership. If you need additional API calls, monthly subscription plans will be available for purchase sometime after the beta is officially over. This is the expected pricing:

  • 500,000 calls/month: Included with membership
  • 1 million calls/month: US$ 49.99
  • 2 million calls/month: US$ 99.99
  • 5 million calls/month: US$ 249.99
  • 10 million calls/month: US$ 499.99
  • 20 million calls/month: US$ 999.99

Apple’s WeatherKit documentation consistently says “Apple Developer Program membership”, which seems to indicate you need to give them money every year to use the REST API. We’ll see if that’s truly the case after the service leaves beta status.

FIN

Kick the tyres & drop issues/PRs as one may be wont to do.

In today’s newsletter Leonardo, an open source project and free online too from Adobe that lets you make great and accessible color palettes for use in UX/UI design and data visualizations! You can read the one newsletter section to get a feel for Leonardo, then go play with it a bit.

The app lets you download the palettes in many forms, as well as just copy the values from the site. Two of the formats are SVG: one for discrete mappings (so, a small, finite number of colors) and another for continuous mappings (so, a gradient). I’ll eventually add the following to my {swatches} package, but, for now, you can tuck these away into a snippet if you do end up working with Leonardo on-the-regular.

Read a qualitative leonardo SVG palette

This is a pretty straightforward format to read and transform into something usable in R:

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="616px" height="80px" aria-hidden="true" id="svg">
    <rect x="0" y="0" width="80" height="80" rx="8" fill="#580000"></rect>
    <rect x="88" y="0" width="80" height="80" rx="8" fill="#a54d15"></rect>
    <rect x="176" y="0" width="80" height="80" rx="8" fill="#edc58d"></rect>
    <rect x="264" y="0" width="80" height="80" rx="8" fill="#ffffe0"></rect>
    <rect x="352" y="0" width="80" height="80" rx="8" fill="#b9d6c7"></rect>
    <rect x="440" y="0" width="80" height="80" rx="8" fill="#297878"></rect>
    <rect x="528" y="0" width="80" height="80" rx="8" fill="#003233"></rect>
</svg>

which means {xml2} can make quick work of it:

read_svg_palette <- \(path) {
  xml2::read_xml(path) |> 
    xml2::xml_find_all(".//d1:rect") |> 
    xml2::xml_attr("fill")
}

pal <- read_svg_palette("https://rud.is/dl/diverging.svg")

scales::show_col(pal)

Read a gradient leonardo SVG palette

The continuous one is only slightly more complex:

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="800px" height="80px" aria-hidden="true" id="gradientSvg">
    <rect id="gradientRect" width="800" height="80" fill="url(#gradientLinearGrad)" rx="8"></rect>
    <defs id="gradientDefs">
        <linearGradient id="gradientLinearGrad" x1="0" y1="0" x2="800" y2="0" gradientUnits="userSpaceOnUse">
            <stop offset="0" stop-color="rgb(88, 0, 0)"></stop>
            <stop offset="0.04081632653061224" stop-color="rgb(123, 37, 6)"></stop>
            <stop offset="0.08163265306122448" stop-color="rgb(153, 65, 16)"></stop>
            <stop offset="0.12244897959183673" stop-color="rgb(179, 90, 25)"></stop>
            <stop offset="0.16326530612244897" stop-color="rgb(203, 115, 34)"></stop>
            <stop offset="0.20408163265306123" stop-color="rgb(222, 139, 51)"></stop>
            <stop offset="0.24489795918367346" stop-color="rgb(230, 166, 94)"></stop>
            <stop offset="0.2857142857142857" stop-color="rgb(236, 190, 130)"></stop>
            <stop offset="0.32653061224489793" stop-color="rgb(240, 210, 160)"></stop>
            <stop offset="0.3673469387755102" stop-color="rgb(245, 227, 184)"></stop>
            <stop offset="0.40816326530612246" stop-color="rgb(249, 241, 204)"></stop>
            <stop offset="0.4489795918367347" stop-color="rgb(252, 250, 217)"></stop>
            <stop offset="0.4897959183673469" stop-color="rgb(254, 254, 222)"></stop>
            <stop offset="0.5306122448979592" stop-color="rgb(251, 252, 222)"></stop>
            <stop offset="0.5714285714285714" stop-color="rgb(242, 248, 220)"></stop>
            <stop offset="0.6122448979591837" stop-color="rgb(229, 240, 216)"></stop>
            <stop offset="0.6530612244897959" stop-color="rgb(210, 229, 209)"></stop>
            <stop offset="0.6938775510204082" stop-color="rgb(188, 216, 201)"></stop>
            <stop offset="0.7346938775510204" stop-color="rgb(160, 202, 189)"></stop>
            <stop offset="0.7755102040816326" stop-color="rgb(126, 186, 178)"></stop>
            <stop offset="0.8163265306122449" stop-color="rgb(74, 170, 167)"></stop>
            <stop offset="0.8571428571428571" stop-color="rgb(53, 147, 146)"></stop>
            <stop offset="0.8979591836734694" stop-color="rgb(42, 122, 121)"></stop>
            <stop offset="0.9387755102040817" stop-color="rgb(28, 94, 95)"></stop>
            <stop offset="0.9795918367346939" stop-color="rgb(9, 65, 66)"></stop>
        </linearGradient>
    </defs>
</svg>

Which means we have to do a tad bit more work in R:

read_svg_gradient <- \(path) {

  xml2::read_xml(path) |> 
    xml2::xml_find_all(".//d1:stop") -> stops

  stringi::stri_replace_last_fixed(
    str = xml2::xml_attr(stops, "stop-color"),
    pattern = ")",
    replacement = ", alpha = 255, maxColorValue = 255)"
  ) -> rgbs

  list(
    colours = lapply(rgbs, \(rgb) parse(text = rgb)) |> 
      sapply(eval) |> 
      stringi::stri_replace_last_regex("FF$", ""),
    values = as.numeric(xml2::xml_attr(stops, "offset"))
  )

}

svg_grad <- read_svg_gradient("https://rud.is/dl/diverging-gradient.svg")

scales::show_col(svg_grad$colours)

We can use the continuous palette with ggplot2::scale_color_gradientn():

df <- data.frame(
  x = runif(100),
  y = runif(100),
  z1 = rnorm(100),
  z2 = abs(rnorm(100))
)

ggplot2::ggplot(df, ggplot2::aes(x, y)) +
  ggplot2::geom_point(ggplot2::aes(colour = z1)) +
  ggplot2::scale_color_gradientn(
    colours = svg_grad$colours,
    values = svg_grad$values
  ) +
  hrbrthemes::theme_ft_rc(grid="XY") 

FIN

Short post, but hopefully a few folks are inspired to try Leonardo out.