36  JavaScript Graphics

Published

September 10, 2026

quarto natively supports Observable JS graphics, which enhance vanilla JavaScript using a “reactive runtime” that implements Shiny-like reactivity. This is particularly useful when working with interactive graphics.

36.1 Objectives

  • Use Observable.js to create interactive graphics
  • Understand how to include Observable.js graphics in quarto documents
  • Create animated or interactive charts using Observable.js that facilitate viewer understanding

36.2 Resources

36.3 Introduction

Observable graphics can be created in two ways: via a hosted service at https://observablehq.com/, and via the Observable JS (“OJS”) core library scripts, which can be included into standalone documents (like quarto). In fact, Quarto handles this natively, and will include the necessary libraries if you use an {ojs} executable code block.

First, we do a bit of preprocessing in R to get our data into a form we can use.

library(dplyr)
library(tidyr)
frogid <- read.csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2025/2025-09-02/frogID_data.csv')
frognames <- read.csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2025/2025-09-02/frog_names.csv') |>
  group_by(scientificName) |> 
  slice_head(n=1)
frogs <- dplyr::left_join(frogid, frognames, by = "scientificName") |>
  mutate(dateTime = paste0(eventDate, "T", eventTime, " ", timezone)) |>
  select(-eventDate, -eventTime, -timezone)
write.csv(frogs, "../data/frogs.csv")
ojs_define(frogs = frogs) # JSname = Rname
land = FileAttachment("../data/australian-states.json").json()
proj = Object({type: "stereographic", rotate: [-130, 30], domain: land})
Plot.plot({  
  projection: proj,
  marks: [
    Plot.graticule(),
    Plot.geo(land, {fill: "#aaa", fillOpacity: 0.2}),
    Plot.sphere(),
    Plot.dot(transpose(frogs), {x: "decimalLongitude", y: "decimalLatitude", stroke: "subfamily", fill: "subfamily", fillOpacity: 0.2}),
    Plot.dot(transpose(frogs), Plot.pointer({x: "decimalLongitude", y: "decimalLatitude", stroke: "subfamily", fill: "subfamily"}))
  ]
})

You can see this example in an observable notebook here.

colorscale = d3.scaleOrdinal().domain(frogs.map(d => d.subfamily)).range(["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"])

map = {
  // You'll often see Leaflet examples initializing a map like L.map('map'),
  // which tells the library to look for a div with the id 'map' on the page.
  // In Observable, we instead create a div from scratch in this cell, so it's
  // completely self-contained.
  let container = DOM.element('div', { style: `width: ${ width }px; height: ${640}px;` });
  
  // Note that I'm yielding the container pretty early here: this allows the
  // div to be placed on the page. This is important, because Leaflet uses
  // the div's .offsetWidth and .offsetHeight to size the map. If I were
  // to only return the container at the end of this method, Leaflet might
  // get the wrong idea about the map's size.
  yield container;
  
  // Now we create a map object and add a layer to it.
  let bbox = [113.338953078, -43.6345972634, 153.569469029, -10.6681857235], // bounding box for Australia
    fitBounds = [ [bbox[1], bbox[0]], [bbox[3], bbox[2]] ]; // formatted for leaflet
  let map = L.map(container).fitBounds(fitBounds);
  
  let osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

  let pointLayer = L.layerGroup();
  for (const row of frogs) {
    const latitude = row.decimalLatitude;
    const longitude = row.decimalLongitude;
    const name = row.scientificName || ''; // Optional: Use a 'name' column for the popup

    if (latitude & longitude) {
      const marker = L.circleMarker([latitude, longitude], {
        fillColor: colorscale(row.subfamily), 
        color: colorscale(row.subfamily), 
        radius: 3,
        fillOpacity: 0.2});
      // Optional: Add a popup if you have a name column.
      if (name) {
        marker.bindPopup(`<h3>${name}</h3>${row.dateTime}<br/>${row.commonName}`);
      }
      pointLayer.addLayer(marker);
    }
  }
  
  pointLayer.addTo(map);
}

Here, we use Leaflet within Observable to get a similar (but more interactive) result. Notice that while Observable’s plot syntax is variable/column oriented, Leaflet (and JavaScript more generally) is row oriented, and so we have to use a for loop to go through each row in the data set and add a circleMarker with a corresponding popup. This increases the rendering time for the plot, which is not great, and might suggest figuring out optimizations like grouping points together at some zoom levels to reduce the rendering load. That’s a level of sophistication that I’ll leave to someone else to demonstrate.

36.4 Observable + VegaLite

Vega Lite is a charting library for JavaScript that generates charts with some basic interactivity enabled (mostly tooltips). It works with Python via Altair as well. VegaLite was inspired by the grammar of graphics but does not necessarily interpret the grammar in the same way that ggplot2 does.

This tutorial is borrowed/modified in part from an Observable notebook, Charting with Vega-Lite. I’ll be using memes data from https://api.imgflip.com/get_memes.

While Observable allows chunks to be in any order (and it will figure out which one needs to run first), this is one of my least favorite aspects of non-R notebooks (jupyter also allows nonlinear execution), and so I write my JS code linearly.

import { vl } from "@vega/vega-lite-api" // Import vega-lite libraries

memes = FileAttachment("../data/memes.json").json() // Add data

vl.markCircle()                         // Make a scatter chart
  .data(memes)                          // Using the memes data 
  .encode(
    vl.x().fieldQ("box_count"),         // For x, use the box_count field
    vl.y().fieldQ("captions"),          // For y, use the captions field
    vl.tooltip().fieldN("url")          // For tooltips, show the url field
  ).render()