PCA with webR

Dataviz logo representing a ScatterPlot chart.

In a previous post I rebuilt a Factoshiny style PCA tool by porting the math to JavaScript. It works, but it is a reimplementation: every future feature of FactoMineR would have to be ported by hand.

So here is the other approach. This page runs the actual R package, in your browser, through webR. The charts are still React and d3.js, but every number on them comes from a real call to FactoMineR::PCA().

Useful links

🎮 The tool

Press the button and wait. Your browser is about to download an entire R interpreter, plus FactoMineR and the packages it depends on. That is a few tens of megabytes, so it takes something like 30 seconds the first time. Everything is cached afterwards.

Once R is up, the controls behave like any other web app: each toggle sends a fresh PCA() call to R and redraws. The status bar shows how long R took.

Start R

This page runs the real FactoMineR package, so it first has to download an R interpreter compiled to WebAssembly and the package library that goes with it. Expect something like 30 seconds and a few tens of megabytes on the first visit. Your browser caches all of it afterwards.

🤓 How it works

webR is R itself, compiled to WebAssembly. It runs in a web worker, so the interpreter never blocks the page, and it comes with a package repository of CRAN builds compiled for the same target. FactoMineR is in there, which is the only reason this page is possible.

→ Loading R

The one thing to be careful about in a Next.js app is that webR must never be bundled. It is loaded from its CDN with a dynamic import that the bundler is told to leave alone:

// Load webR from its CDN, at runtime, so the bundler never sees it
const { WebR } = await import(
  /* webpackIgnore: true */ /* turbopackIgnore: true */
  'https://webr.r-wasm.org/v0.6.0/webr.mjs'
);

const webR = new WebR({ baseUrl: 'https://webr.r-wasm.org/v0.6.0/' });
await webR.init();
await webR.installPackages(['FactoMineR']);

// Hand the data over through webR's virtual filesystem
await webR.FS.writeFile('/tmp/data.csv', new TextEncoder().encode(csv));

// Run R, and ship the result back as JSON
const shelter = await new webR.Shelter();
const output = await shelter.evalR(`
  res <- FactoMineR::PCA(df, graph = FALSE)
  jsonlite::toJSON(list(eig = unname(res$eig[, 1])), digits = 10)
`);
const result = JSON.parse(await output.toString());
shelter.purge();

Data goes in through webR's virtual filesystem as a csv, and results come back as a JSON string built by jsonlite. Passing plain text in both directions avoids any guesswork about how an R matrix maps onto a JavaScript object.

→ The R that runs

There is no trick here. The script below is what gets evaluated, and you can see the live version of it at any moment with the Show the R code button above the charts.

# This is what actually runs, in a WebAssembly R session
df <- read.csv("/tmp/data.csv", row.names = 1, check.names = FALSE)

X <- df[, c("Murder", "Assault", "UrbanPop", "Rape"), drop = FALSE]

res <- FactoMineR::PCA(
  X,
  scale.unit = TRUE,
  ncp = 4,
  quali.sup = NULL,
  graph = FALSE
)

→ No special headers needed

webR is faster when the page is cross origin isolated, which needs COOP and COEP headers. This gallery embeds a lot of third party iframes, and those headers would break them, so the page does not set them. webR notices and falls back to a postMessage channel on its own. The only things lost are interrupting a running computation and reading from stdin, neither of which a PCA needs.

⚖️ Is it worth it?

Both versions of this tool draw the same charts from the same datasets, so the comparison is unusually clean.

→ What webR wins

  • It is the real thing. No reimplementation to keep in sync, no doubt about whether an edge case matches R.
  • Everything the package offers is one line away. The category barycentres and the v.test table on this page come from quali.sup, a single argument. Adding them to the JavaScript version meant writing them.
  • The rest of CRAN is available too. Once R is loaded, adding clustering or a mixed data analysis is an install.packages() away.

→ What it costs

  • The first load. Tens of megabytes and about half a minute, against a few kilobytes for the JavaScript version. That is the whole story, and it is a big one for a public page.
  • No server rendering. The charts cannot exist until R is up, so there is nothing to show a crawler and nothing to show a visitor who leaves after 5 seconds.
  • A heavier moving part. A WebAssembly R session in a worker is more to go wrong than a function that multiplies matrices.

Once R is warm though, the cost disappears: FactoMineR::PCA() comes back in a handful of milliseconds on these datasets, which is fast enough to recompute on every click.

So the answer is the boring one. For a small, well understood computation that you want on a public page, port it: the JavaScript version loads instantly and weighs nothing. For an internal tool, a teaching app, or anything that leans on what R packages actually contain, webR saves you from rewriting a library that already exists.

Conclusion

The 2 versions agree to 8 decimal places on every dataset here, which was a good way to check the hand written one. That is probably the most practical use of webR for a JavaScript developer: not necessarily shipping it, but keeping a real R session around to test your port against.

Correlation

Contact

👋 Hey, I'm Yan and I'm currently working on this project!

Feedback is welcome ❤️. You can fill an issue on Github, drop me a message on LinkedIn, or even send me an email pasting yan.holtz.data with gmail.com. You can also subscribe to the newsletter to know when I publish more content!