PlotCat

PlotCat is a Quarto extension for plot-recreation exercises. Authors provide a target plot and optional starter code. Students write R or Python in the browser, render their plot with WebR or Pyodide, and compare it with the target.

PlotCat compares SVG structure and plot text for static plots. It compares Plotly traces and layout separately. Plotly exercises use side-by-side comparison; overlay and wipe apply to SVG plots. PlotCat obfuscates the target source in the rendered HTML to discourage casual inspection.

Requirements

PlotCat publishes interactive HTML only. Its browser runtimes need network access on first use and package availability in WebR or Pyodide. The included examples cover base R, ggplot2, tinyplot, lattice, matplotlib, plotnine, seaborn, and Plotly.

Installation

Add Quarto Live, then PlotCat:

quarto add r-wasm/quarto-live
quarto add VisruthSK/PlotCat

Then enable the filter in a document or project config:

format:
  live-html:
    webr:
      packages:
        - svglite
        - ggplot2

    pyodide:
      packages:
        - matplotlib

filters:
  - plotcat 

R pages using PlotCat must declare svglite. Authors must declare every non-base R or Python package used by target or starter code. PlotCat does not infer or install runtime packages. Package configuration belongs to Quarto Live.

For a single document, put webr and pyodide options in the document front matter. In project configuration, nest them under live-html to avoid metadata-merging problems.

live-html supplies the native WebR and Pyodide editors. Set #| eval: false on every PlotCat chunk so Quarto leaves it for the browser runtime.

Example

Write the target plot as the first chunk inside a .plotcat Div and set #| eval: false. PlotCat renders the target and the student’s submission in the browser. Apply the option to every PlotCat chunk: Quarto decides whether to execute chunks before PlotCat’s filter runs.

::: {.plotcat}

::: {.cell}

```{.r .cell-code}
tinyplot::tinyplot(
  dist ~ speed,
  data = cars,
  main = "Stopping distance by speed",
  xlab = "Speed",
  ylab = "Stopping distance"
)
```
:::

:::

Add a second chunk for starter code. Set #| eval: false on the starter chunk.

::: {.plotcat}

::: {.cell}

```{.r .cell-code}
tinyplot::tinyplot(
  dist ~ speed,
  data = cars,
  main = "Stopping distance by speed",
  xlab = "Speed",
  ylab = "Stopping distance"
)
```
:::



::: {.cell}

```{.r .cell-code}
tinyplot::tinyplot(
  dist ~ speed,
  data = cars
)
```
:::

:::

The first chunk becomes the browser-rendered target, and its source is obfuscated in the rendered HTML. The second chunk appears in the editor.

PlotCat accepts R and Python chunks. Both chunks in an exercise must use the same language.

Use either {r} or {webr} for R, and either {python} or {pyodide} for Python. PlotCat turns the student editor into Quarto Live’s native webr or pyodide cell.

Weights

PlotCat scores student plots against a target using a weighted comparison. You can override individual weights per exercise as attributes on the .plotcat Div.

Unset attributes keep their defaults.

Global defaults

Set project-wide or document-wide weights under plotcat.weights in YAML frontmatter. Per-exercise attributes on the .plotcat Div override these.

plotcat:
  weights:
    svg:
      geometry: 0.5
      text: 0.25
    plotly:
      data: 0.5
      layout: 0.2

SVG weights

Attribute Default Controls
svg-geometry 0.6 Element positions (bag overlap of coarse geometry)
svg-text 0.15 Text content match
svg-style 0.1 Fill, stroke, opacity
svg-frame 0.15 Aspect ratio

Raise svg-text when axis labels matter more in a scatter exercise. Raise svg-geometry when missing bars should reduce a bar-chart score.

Plotly weights

Attribute Default Controls
plotly-trace 0.3 Trace type and count
plotly-data 0.4 All non-style trace data, including coordinates, names, hover text, and customdata
plotly-style 0.2 Trace styling, including marker, line, fill, and opacity settings
plotly-layout 0.1 Layout, animation frames, and Plotly configuration

PlotCat grades every serialized target property in one of these categories. Extra properties inside a student object are allowed, but missing or extra array entries reduce the score.

Example

::: {.plotcat id="scatter"
    svg-geometry="0.4"
    svg-text="0.4"}

The final score sums each category multiplied by its top-level weight: geometry * svg-geometry + text * svg-text + style * svg-style + frame * svg-frame.

Dimensions

Override the default plot dimensions per exercise using plotcat-width and plotcat-height attributes on the .plotcat Div. The default is 7×5 in for both R and Python.

Set document-wide defaults under plotcat.dimensions in YAML frontmatter:

plotcat:
  dimensions:
    width: 8
    height: 6
::: {.plotcat id="tall-plot" plotcat-width="4" plotcat-height="6"}

Heading

Override the heading text with plotcat-heading on the .plotcat Div or plotcat.heading in YAML frontmatter. Defaults to “Recreate this plot”.

::: {.plotcat id="labeled" plotcat-heading="Reproduce the graphic"}
plotcat:
  heading: "Reproduce the graphic"

Limitations

  • Font and text styling: SVG comparison checks text content, but ignores font families, font sizes, font weights, and text positioning.
  • Coarse geometry matching: Coordinates are rounded to ~0.1% relative precision and matched as unordered marks. Minor position shifts or point jittering may still yield high scores.
  • No code inspection: PlotCat evaluates rendered plot output (SVG or Plotly JSON), not R or Python code structure. It cannot check for specific function calls or idioms.
  • Plotly support: Plotly exercises use side-by-side comparison and evaluate a subset of trace data, mark attributes, and titles.
  • Runtime sandbox: WebR and Pyodide run in the browser WebAssembly sandbox, so code cannot access files on the local filesystem (e.g., read.csv("C:/data.csv")). Remote data files can be fetched directly with functions such as read.csv("https://...") in R or pd.read_csv("https://...") in Python. Required packages must be available in WebAssembly.

Design Inspiration

PlotCat was inspired by ggplot2 Battles, brought to my attention by Emily Robinson.