Bill Mill web site logo

Working with Observable Plot

Yesterday I wanted to explore how well NBA players played in the first round of the playoffs. Normally I would reach for d3 to build a visualization of their performance, but I had been looking for an excuse to play with Observable Plot so I gave it a spin.

After I wrote a little data cleaning script that generated a json data file, I was ready to give it a go.

This article walks you through how I made three graphics, none of which are finished, but that will maybe be enough to give you an idea of how you might work with observable plot, and whether you want to.

The graphics on this page are not static images, but are generated by the code shown on the page when you load it. (Please tell me if something doesn't work.)

Contents

Figures

Getting started

The installation instructions provide instructions for installing via yarn or npm, javascript modules, or via legacy script tags.

Since I'm an old person who doesn't wish to mess around with npm if I can avoid it, I naturally chose the legacy option that let me get started quickly.

Here's a simple HTML page that will load the required scripts (d3 is a dependency of observable plot):

<html>
  <head>
    <title>Playoff Performance</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.4"></script>
<script src="index.js"></script>
  </head>
  <body>
  </body>
</html>

In just a few minutes, by triangulating between the example in the installation docs and the examples on Observable, I was up and running with a simple scatterplot.

async function main() {
  const res = await fetch(`data.json`);
  const stats = await res.json();
  const dotplot = Plot.dot(stats, {
    x: "usg_pct",
    y: "ts_pct",
    stroke: "team",
    title: "name",
  }).plot();
  document.body.append(dotplot);
}

window.addEventListener("DOMContentLoaded", async (_evt) => {
  await main();
});

Which looks like this:

Hey that's nice! It's not a great graphic, but it was super quick to go from data to a visualization. Much quicker than if I had used plain d3.

Let's do something More Complicated

If a little is good, more must be better, so I wanted to try building something more complicated.

I thought it would be cool to make a grid splitting up the players by team, and charting the difference in their true shooting percentage (a measure of how efficiently a player scores) and usage percentage (how often the player shoots or turns the ball over).

The following graph is hard to read, and I'm not pitching it as a good visualization; I'm just trying to review Observable plot by showing my process of exploring the data. The dot's location is a player's playoff true shooting and usage percentage, and the line leads to their regular season shooting and usage.

A player with a line going up and to the right shot worse and used less possessions in the playoffs than in the regular season; a player with a line going down and to the left shot better and used more.

It took me about an hour to get to this:

function smallMultipleGraph(stats) {
  const players = stats.filter((x) => x.playoff_mpg > 28);

  grid = [
    ["MIA", "ATL", "BOS", "BRK"],
    ["MIL", "CHI", "PHI", "TOR"],
    ["PHO", "NOP", "MEM", "MIN"],
    ["GSW", "DEN", "UTA", "DAL"],
  ];

  // label each player with the appropriate row and column by the team they
  // play on. Extremely inefficient and also takes negligible time. I try to
  // turn off my backend engineering instincts when doing data analysis
  for (i = 0; i < 4; i++) {
    for (j = 0; j < 4; j++) {
      for (const player of players) {
        if (player.team == grid[i][j]) {
          player.row = i;
          player.col = j;
        }
      }
    }
  }

  const xfield = "playoff_usg_pct";
  const yfield = "playoff_ts_pct";
  const width = 1024;
  const height = 800;
  const ffmt = d3.format(".1f");

  const plot = Plot.plot({
    grid: true,     // show gridlines
    width: width,
    height: height,
    x: {
      domain: d3.extent(players, (d) => d[xfield]),
      nice: true,   // round the scale to nice numbers
      label: "→ usage percentage",
    },
    y: {
      domain: d3.extent(players, (d) => d[yfield]),
      nice: true,
      label: "↑ true shooting",
    },
    facet: {
      data: players,
      // Here we use the "col" and "row" variables we added earlier to facet
      // the data the way we wanted
      x: "col",
      y: "row",
    },
    // "fx" is the x scale for each facet
    fx: {
      // it took me forever to figure out that this is how to suppress the
      // facet labels - in our case they're useless, just the row and col
      // variables we add above
      axis: null,
    },
    fy: {
      axis: null,
      paddingInner: 0.2,
    },
    marks: [
      Plot.dot(players, {
        x: xfield,
        y: yfield,
        title: "name",
        r: 10,
        fill: (d) => teams[d.team].colors[0],
        stroke: (d) => teams[d.team].colors[1],
        strokeWidth: 5,
      }),
      // draw the line between regular season and playoff performance
      Plot.link(players, {
        x1: "usg_pct",
        y1: "ts_pct",
        x2: xfield,
        y2: yfield,
      }),
    ],
  });

  // Add labels to the facets. There's no way I can tell to do this within the
  // plot?
  d3.select(plot)
    .selectAll("[aria-label=facet]")
    .append("text")
    .attr("x", width / 8)
    .attr("y", -12)
    .attr("font-weight", "bold")
    .attr("font-size", "1.2em")
    .text((d, i) => grid[d[1]][d[0]]);

  return plot;
}

Glitches in the matrix

I was very impressed with what I was able to do while mostly staying within the boundaries of the plot method. Plot let me express a quite complicated graphic with a tiny bit of pre-processing and then a mostly declarative description of the marks and axes.

The first thing I found that I couldn't do within the declarative framework was place a label for each facet. There is a text mark, but unfortunately it is only usable within the facet, so the only way I could figure out to label each facet was to use d3 and place text objects within the SVG manually.

The next thing I couldn't figure out was how to label the players. I could have used text marks, but they would have gotten jumbled with the dots on the graph. What I wanted to do was to make the user able to mouse over them and get a label; I tried addinggg the title attribute, but that didn't seem to work.

Some googling revealed that mouse interaction is still an open issue on plot, and isn't currently supported. To get my tooltips, I ended up modifying this observable notebook into this javascript file, which took me quite a while to get working and which I never bothered to fully understand, and doesn't work perfectly.

(You can go here to see them in action, though be warned it does not work perfectly - if your browser window doesn't show the full width graphic, the tooltips will be misaligned).

It would be nice if dots were automatically labeled with a tooltip; when I did an exploration into Altair I was grateful that they were put on the graph automatically.

When you reach the boundaries of what can be declared in a declarative system, things start to get messy.

Take 2

It took me a while to get that graphic working, but I wasn't very happy with it as a graphic - it's difficult to understand and I didn't feel like it would merit me writing up how to interpret it.

(Side note: I've learned that it's very easy to love your own graphics that you've built, you learn to interpret them as you build them. If they're not very easy to interpret, when you try to present them to people, they can be overwhelmed and will often be quite unkind in their criticism. Most people want very easy to digest graphics, and have strong expectations for the form they take.)

My next idea was to create a bar chart stacked vertically, with the change in true shooting represented with a bar to the right if it improved, and a bar to the left it it got worse. I also wanted to show the change in usage percentage, so I used the color of the bar to represent that. The unpolished and unfinished result is below. (Again, it's an interesting but not actually good visualization that I wouldn't present on its own)

The idea for this one owes to Owen Phillips, whose visualizations are an inspiration to me.

This one actually took me forever to figure out, despite being much shorter than the previous example, largely because I tried to use facets to vertically stack the graph rather than just the y scale. I was able to get it working only once I found this example in the docs.

const players = stats.filter((x) => x.playoff_mpg > 30);
for (const p of players) {
  p.ts_diff = p.playoff_ts_pct - p.ts_pct;
  p.usage_diff = p.playoff_usg_pct - p.usg_pct;
}

const sorted = d3.sort(players, (d) => -d.ts_diff);

const plot = Plot.plot({
  grid: true,
  marginLeft: 130,
  x: {
    label:
      "← decrease · Change in True Shooting percentage in playoffs · increase →",
    labelAnchor: "center",
    domain: d3.extent(sorted, (d) => d.ts_diff),
    nice: true,
    axis: "top",
  },
  y: {
    label: null,
    domain: sorted.map((d) => d.name),
  },
  color: {
    scheme: "rdylgn",
    legend: true,
  },
  marks: [
    Plot.barX(sorted, {
      y: "name",
      x: "ts_diff",
      fill: "usage_diff",
    }),
    Plot.ruleX([0]),
  ],
});

I'm impressed with how compact the final representation for this one is.

My main complaint here is that it seems to be impossible to change the label on the legend. The docs explain how to generate a separate legend, but not how to change the legend in your graphic. If it's possible, it would be great for the docs to mention it - and if not, to demonstrate how to add the legend generated by plot.legend to your graphic.

I don't like the way true shooting and usage combine in this graphic, but I think it's headed in the right direction. If I were to continue with it, I might try adding a second column for change in usage, while keeping change in true shooting as it is. At that point, it becomes kind of an annotated table, which is a visualization I've often come back to.

Overall Impressions

I was impressed with how quickly I was able to get several visualizations up and running, and how much I was able to express within its framework. With a little more practice in the library, I think I would favor it for quick and dirty visualizations.

Once I was trying to polish things up, I found it pretty limiting, so I think for now I would continue to reach for plain d3 when making graphics for publication.

For me to be able to use it for more presentational graphics, I'd love to see it grow advanced labeling capabilities, both with text label placement and with tooltip integration. I think the legend feature, which is new-ish, could use some polishing up.

I had a good time creating these graphics, and appreciate all the work that went into this library and has gone into d3 over the years.

(Go Celtics!)