36  JavaScript Graphics

Published

October 29, 2025

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 An Introduction to Observable

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 Observable natively, and will include the necessary libraries if you use an {ojs} executable code block. OJS chunks are different than R and python chunks, in that they do not work in interactive mode - you will only be able to see the result when the document compiles. Observable also has a radically different model for how notebooks run that leads to chunks not being loaded in order. As a result, you cannot define variables more than once (because this messes with the dependency diagrams) or import libraries multiple times in a single notebook.

I’ve attempted to only import libraries once, the first time they are used, but this can be a bit confusing.

36.2.1 Sharing Data with Observable

For this example, we’ll use the Australian Frogs data from TidyTuesday, provided by the Australian Society of Herpetologists and JJL Rowley & CT Callaghan, and curated by Jessica Moore.

You can see this example in an observable notebook here.

First, we do a bit of preprocessing in R to get our data into a single data frame so that it is easy to import into JavaScript.

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 

The last step is to use the ojs_define() function in R or python to make the frogs data available to OJS chunks. The argument name is the name you’ll use to refer to the data in the OJS chunk, and the argument value is the name of the data in R or python. Note that this will not work until the document is compiled, because OJS does not work interactively in quarto notebooks, so if you try to run this R chunk interactively, you will get an error about ojs_define not being defined.

36.2.2 Observable Plots (Maps)
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, {color: "#000", fill: "#aaa", fillOpacity: 0.2}),
    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"
    }))
  ]
})
1
Load the australian state borders
2
Set the map projection. domain: land ensures that the longitude limits fully include Australia. rotate: [-130,30] sets the map projection rotation. Without the domain argument, the rotate argument gives a sideways Australian map.
3
Plot.graticule = grid lines for the map projection
4
Plot.geo plots a map, with the australian states outlined in black and filled in light grey
5
Plot.dot provides dots for each frog observation
6
JS handles things rowwise, so we transpose the data
7
stroke = outline of the point
8
fill = inside of the point
9
Opacity is handled separately for stroke and fill.
10
This pointer object reacts to whichever point is closest to the mouse. The differences in arguments compared to the Plot.dot() object above mean the fill will be fully opaque and the point will look solid.
36.2.3 Observable + Leaflet

First, we import d3 version 7, and ensure that this chunk isn’t cached so that any other chunks which use d3 will not be screwed up when we recompile.

d3 = require("d3@7")
frogs2 = FileAttachment("../data/frogs.csv").csv({"typed": true}) 

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

map = {

  let container = DOM.element('div',
    { style: `width: ${ width }px; height: ${640}px;` }
  );
  
  yield container;
  
  let bbox = [113.338953078, -43.6345972634, 153.569469029, -10.6681857235],
    fitBounds = [ [bbox[1], bbox[0]], [bbox[3], bbox[2]] ];
  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 frogs2) {
    const name = row.scientificName || 'unknown';

    if (row.decimalLatitude & row.decimalLongitude) {
      const marker = L.circleMarker(
        [row.decimalLatitude, row.decimalLongitude],
        {
          fillColor: colorscale(row.subfamily),
          color: colorscale(row.subfamily),
          radius: 3,
          fillOpacity: 0.2
        }
      );
        
      if (name) {
        marker.bindPopup(
        `<h3>${name}</h3>${row.dateTime}<br/>${row.commonName}`
        );
      }
      
      pointLayer.addLayer(marker);
    }
  }
  
  pointLayer.addTo(map);
}
1
“Subfamily” is the name of the column we want to color by. “frogs” is the name of the dataset. The domain is the list of unique values in the column, and the range is the array of colors we want to use.
2
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. Instead, here, we create the full object as a self-contained entity.
3
This code creates a div from scratch and sets the width and height of the container.
4
The yield statement allows the container/div to be placed on the page. Doing this early allows Leaflet to use the div’s .offsetWidth and .offsetHeight to size the map. Waiting till later might give Leaflet the wrong dimensions for the map, which could lead to rendering issues.
5
This creates a bounding box for Australia, formats it for Leaflet, and then applies the bounding box.
6
Then, we use Open Street Map to add tiles to the map corresponding to Australia.
7
A LayerGroup keeps things that are all the same together.
8
JavaScript works row-wise, so we need a for loop to get through each row in the data set.
9
This allows us to specify that if there is no scientificName defined, to just use a pre-specified string (‘unknown’, in this case). This will make the popup a bit easier to understand.
10
JavaScript allows if statements to test for whether a value is defined – so if there is not a latitude and longitude, then we just don’t plot a point.
11
circleMarker is just one option - you can use marker, circleMarker, or even custom marker types defined by your own icons. A non-circle marker is like a pin in a map, but there are fewer customization options (e.g. opacity, fill, color), and so with the number of points we have, we want to be able to use opacity in particular.
12
This provides the longitude (x) and latitude (y).
13
This sets the aesthetic mappings for appearance (color, fillColor) as well as constant values that are not mapped to variables (radius, fillOpacity). These are provided inside {} as a set of key-value pairs.
14
This creates the formatted pop-up with the scientific name and the time which the observation was made. I’m sure we could format that time to be more useful, but for now let’s just leave it be.
15
We then add the point and popup to the layer of points.
16
The final step is to add the pointLayer to the 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. The sheer number of points 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.3 Observable + VegaLite

This visualization curriculum would probably have been a suitable “go here for a different textbook” chapter if I’d found it soon enough in my dig through Observable. Oh well.

This YouTube video shows a talk at OpenVis 2016 by Arvind Satyanarayan, creator of Vega. Vega is a JavaScript library built on top of d3.js, a lower-level JavaScript library for interactive graphics.

This is an OpenVis 2016 talk by Wongsuphasawat, Moritz, and Satyanarayan about Vega-Lite, a grammar of graphics built on top of Vega.

Vega Lite is a charting library for JavaScript that generates charts with some basic interactivity enabled (mostly tooltips, sliders, animations). It works with Python via Altair as well, which means that you could get comfortable with Vega-Lite across multiple languages. VegaLite was inspired by the grammar of graphics but does not necessarily interpret the grammar in the same way that ggplot2 does.

I’ll be using memes data from https://api.imgflip.com/get_memes. You can see this section in an Observable Notebook if you prefer.

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 try to write my JS code linearly. When you are looking at Observable Notebooks, it’s fairly common to have the dependencies and imports at the bottom along with any data definitions. I find this confusing and irritating, but I suspect that it’s something you get used to over time, if you’re not trying to switch between Observable/jupyter style notebooks and quarto style notebooks.

36.3.1 A Basic Example

First, we’ll load some libraries (vega-lite, SummaryTable) that will be used in this example.

36.3.1.1 Loading Libraries
import { vl } from "@vega/vega-lite-api-v6" // Import vega-lite libraries
import { SummaryTable } from "@observablehq/summary-table"
36.3.1.2 Reading Data from an API
data = fetch("https://api.imgflip.com/get_memes").then((response) => response.json()) 
tmp = data.data.memes

// Add data and format it usefully
// Box Count (jittered) is a jittered categorical variable
// Calculate area of the meme in pixels as well
memes = tmp.map(d => ({
  ...d,
  "Box Count (jittered)": +d.box_count + (Math.random() - 0.5) * 0.5,  // Adjust jitter width as needed
  "Area (px)": +d.width*d.height // get area of the meme in pixels
}))

Just for completeness, it’s helpful to get a look at the data frame, so I’ve included a summary in Section 36.3.1.3.

SummaryTable(memes)

Now that we have our data, we might want to examine the number of versions of each meme, by the number of caption boxes in the meme. A boxplot is typically a good first look at the distribution of variables.

36.3.1.4 Creating a Boxplot

Let’s start out with a boxplot. Vega-Lite distinguishes field types using fieldN (nominal), fieldO (ordinal), and fieldQ (quantitative) options. Quantitative scale axes include 0 by default to adhere to graphical conventions, but this can be disabled by adding .scale({zero: false}) to the end of the field statement. Similarly, you can disable “nice” scale breaks/limits by adding .scale({nice: false}) to the end of the field statement.

vl.markBoxplot({size: 100})        // Make a boxplot, have the boxes be wider
  .data(memes)                     // Using the memes data 
  .encode(
    vl.x().fieldN("box_count"),    // For x, use the box_count field. FieldN says "Nominal" (FieldQ = quantitative)
    vl.y().fieldQ("captions"),     // For y, use the captions field
    vl.tooltip().fieldN("name")    // For tooltips, show the meme name
  )
  .width(500)
  .height(250)
  .render()

However, this only allows us to highlight the outliers. We might instead want to use points so that we can examine each point and get additional tooltip information. If we use \(x = box_count\) directly, then we’ll end up with lots of overplotting. Instead, we can jitter box_count by adding some random noise (which we did above).

36.3.1.5 Scatter Plot with Jittering
vl.markPoint()
  .data(memes)
  .width({"step": 50})
  .encode(
    vl.x().fieldQ("Box Count (jittered)").scale({zero: false}),
    vl.y().fieldQ("captions"),          
    vl.tooltip().fieldN("name")          
  )
  .render()

Of course, we can also map additional information, such as the area of the meme image, to the points.

36.3.1.6 Mapping Additional Aesthetics
vl.markPoint()
  .data(memes)
  .encode(
    vl.x().fieldQ("Box Count (jittered)").scale({zero: false}),
    vl.y().fieldQ("captions"),          
    vl.tooltip().fieldN("name"),
    vl.stroke().fieldQ("Area (px)").scale({scheme: "magma"})
  )
  .render()

36.3.2 Interactivity and Linked Charts

Vega-Lite and Observable can do much cooler things, though - we can select subsets of points across multiple charts, or use a graphic to query another graphic.

For this, we’ll use the movies data included with vega. Some of these examples are modified slightly from the Interaction Observable notebook.

36.3.2.1 Loading Data from JSON
// vl imported above
// d3 imported above

import {uniqueValid} from "@uwdata/data-utilities"
datasets = require('vega-datasets@1')
movies = datasets['movies.json'](); // load dataset
genres = uniqueValid(movies, d => d.Major_Genre);
mpaa = ['G', 'PG', 'PG-13', 'R', 'NC-17', 'Not Rated'];
36.3.2.2 Selectors: Dropdown Menus, Radio Inputs, and more
{  
  const selectGenre = vl.selectPoint('Select')
    .fields('Major_Genre')
    .init({Major_Genre: genres[0]})
    .bind(vl.menu(genres));
  
  return vl.markCircle()
    .data(movies)
    .params(selectGenre)
    .encode(
      vl.x().fieldQ('Rotten_Tomatoes_Rating'), 
      vl.y().fieldQ('IMDB_Rating'),
      vl.tooltip().fieldN('Title'),
      vl.opacity().if(selectGenre, vl.value(0.75)).value(0.05)
    )
    .render();
}
1
Name the selection ‘Select’
2
Limit selection to the Major_Genre field
3
Use the first genre entry as initial value
4
Bind to a menu of unique genre values
5
Create a scatter plot
6
Use the selectGenre menu value as a parameter
7
Change the opacity conditionally so that points in the selected genre are higher opacity

We can even use multiple selector inputs to get a more complex subset of the data.

{  
  const selection = vl.selectPoint('Select')
    .fields('Major_Genre', 'MPAA_Rating')
    .init({
      Major_Genre: 'Drama',
      MPAA_Rating: 'R'
    })
    .bind({
      Major_Genre: vl.menu(genres),
      MPAA_Rating: vl.radio(mpaa)
    });
   
  return vl.markCircle()
    .data(movies)
    .params(selection)
    .encode(
      vl.x().fieldQ('Rotten_Tomatoes_Rating'),
      vl.y().fieldQ('IMDB_Rating'),
      vl.tooltip().fieldN('Title'),
      vl.opacity().if(selection, vl.value(0.75)).value(0.05)
    )
    .render();
}
1
Set up selection wiring
2
Set hard-wired initial selected values of Drama and R rating
3
Create menu for genres, radio buttons for mpaa rating
4
Create scatterplot
5
Use selection values as parameters for the plot

In some situations, it may be more natural to use a plot as a selection mechanism to show data in a second plot. We’ll explore this option next.

36.3.2.3 Plots as Dynamic Queries
{
  const brush = vl
    .selectInterval()
    .encodings('x');
  
  // dynamic query histogram
  const years = vl
    .markBar({width: 4})
    .data(movies) 
    .encode(
      vl.x().year('Release_Date')
            .title('Films by Release Year'),
      vl.y().count()
            .title(null)
    )
    .params(brush)
    .width(600)
    .height(50);
  
  // ratings scatter plot
  const ratings = vl.markCircle()
    .data(movies)
    .encode(
      vl.x().fieldQ('Rotten_Tomatoes_Rating'),
      vl.y().fieldQ('IMDB_Rating'),
      vl.tooltip().fieldN('Title'),
      vl.opacity().if(brush, vl.value(0.75)).value(0.05)
    )
    .width(600)
    .height(400);

  return vl
  .vconcat(years, ratings)
  .spacing(5)
  .render(); 
}
1
Create a selector that can be integrated into the plot.
2
Limit selection to x-axis values so that the y-values don’t matter.
3
Create a histogram
4
Use the selector as a dynamic query within the histogram
5
Connect brush to appearance of the points in the scatterplot
6
Connect the two plots vertically
7
Add a bit of spacing between panels
36.3.2.4 Panning and Zooming

It may be useful in some cases to be able to move around a chart. We can think of this as selecting all of the data lying within the x-axis and y-axis limits. That is, we can define a selection interval of the data that is within the scales!

vl.markCircle()
  .data(movies)
  .params(
    vl.selectInterval().bind('scales') // bind interval selection to scale domains
  )
  .encode(
    vl.x().fieldQ('Rotten_Tomatoes_Rating'),
    vl.y().fieldQ('IMDB_Rating')
      .axis({minExtent: 30}), // add min extent to stabilize axis title placement
    vl.tooltip(['Title', 'Release_Date', 'IMDB_Rating', 'Rotten_Tomatoes_Rating'])
  )
  .width(600)
  .height(450)
  .render()
36.3.2.5 Overview and Detail on Demand

One of the maxims of interactive (and even static) data visualization is “details-on-demand” – that is, the ways that we can get a sense of the data is to allow people to move around the data set (zoom), look at subsets (filter), and then to get details when they’re ready for them.

One easy way to do this is to provide a large-scale overview and then to allow people to zoom in, often using two plots - one that serves as a query selector, and one that shows the resulting data, as in Section 36.3.2.3.

Another way to do this is to provide those details as tooltips and additional layers that respond to the selection.

{
  const hover = vl.selectPoint()
    .on('mouseover')
    .toggle(false)
    .nearest(true);
  
  const click = vl.selectPoint();
  
  const hoverOrClick = vl.or(click.empty(false), hover.empty(false));
  
  const plot = vl.markCircle().encode( vl.x().fieldQ('Rotten_Tomatoes_Rating'),
    vl.y().fieldQ('IMDB_Rating') );
  
  const base = plot.transform( vl.filter(hoverOrClick) );
  
  const halo = { size: 100, stroke: 'firebrick', strokeWidth: 1 };
  const label = { dx: 4, dy: -8, align: 'right' };
  const white = { stroke: 'white', strokeWidth: 2 };

  return vl
    .data(movies)
    .layer(
      plot.params(hover, click),
      base.markPoint(halo),
      base.markText(label, white).encode(vl.text().fieldN('Title')),
      base.markText(label).encode(vl.text().fieldN('Title'))
    )
    .width(600)
    .height(450)
    .render();
}
1
Select a point when the user hovers over it, but not when it’s shift-hovered
2
Select nearest point to the mouse cursor
3
Also select points if they’re clicked on.
4
Combine hover and click selections. Empty selections should match nothing.
5
Define scatter plot encodings shared by all marks.
6
Create a shared base for new layers that has the selectors.
7
Set appearance of new layers
8
Layer scatter plot points, halo annotations, and title labels together, then render the chart.

36.4 References