Josh Pollock with Arvind Satyanarayan
*Sped up 2–4×; actual time ~2.5 min. Placeholder: Arvind's VizChitra demo, to re-record with a scientific dataset.
*Actual time ~15 min. Placeholder: Arvind's VizChitra demo.
The catch
How do you know the figure shows what your reader needs? How do you know what you want in the first place?
For a scientific figure, "impressive" isn't the bar. The bar is: does it support the comparisons your argument depends on?
The way out
To judge a figure, yours or the AI's, you need a way to see what it's doing.
Not "which chart type is this?" but "what does this arrangement let my reader see?"
The examples use fishery data: lakes, species, catch amounts, and locations.
That matters because the data are naturally relational. We care about parts and wholes, where things happened, and how the same species moves through several views.
chart types lack underlying structure
task taxonomies are vague
ossified into AI skills
The problem
This chart is good. This chart is bad. Use this color scheme, not that one.
Rules are useful, but brittle. They tend to be all-or-nothing just when scientific graphics need judgment.
how do we figure out what to make?
how do we make it?
The stance
Not anti-style. Anti brittle rules.
A prerequisite to designing good graphics is seeing what they are doing as clearly as possible, before ranking them by type.
A better lens
Grammar names the relationships. Style controls the voice.
containment, alignment, connection, order, scale, emphasis
palette, texture, typography, density, ornament, tone
chart(heights)
.flow(
spread(by="age", dir="x"),
spread(by="person", dir="x")
)
.mark(rect(h="height", fill="person"))
Observable Plot
Munzner, Visualization Analysis and Design (2014)
Thompson’s growth transformation · via Wilkinson, The Grammar of Graphics
Marks and channels tell us how precisely to read a value. They don't tell us which questions a chart makes easy.
structure changes what becomes easy to see
1 · a language of graphical structure
2 · a language of perceptual queries
speculative, cutting-edge research. please push back!
Runway PL
Jean Yang asks what happens when programming-language ideas get the same public design culture that fashion has.
I want that for scientific figures: couture-level structure, made available for everyday chart decisions.
Jean Yang, “Runway PL” and Fast Fashion · Schiaparelli FW 2026-27
| containment |
|
| connection |
|
| similar attribute |
|
| alignment |
|
| proximity / uniform density |
|
| containment |
|
| connection |
|
| similar attribute |
|
| alignment |
|
| proximity / uniform density |
|
bar_chart(data, x="category", y="value")
chart(data) .flow(spread(by="category", dir="x")) .mark(rect(h="value"))
groups = ( data.group_by("category") .agg(pl.col("value").sum()) ) rects = [ rect(key=d["category"], h=datum(d["value"])) for d in groups.iter_rows(named=True) ] spread(rects, dir="x", alignment="baseline")
groups = ... rects = ... layer(rects).constrain(lambda a, b, c: [ Constraint.align([a, b, c], y="baseline"), Constraint.distribute([a, b, c], dir="x") ])
GoFish walkthrough
chart(barley) .flow( ) .mark( )
chart(barley)
| variety | site | year | yield |
|---|---|---|---|
| Manchuria | University Farm | 1931 | 27.00 |
| Manchuria | Waseca | 1931 | 48.87 |
| Manchuria | Morris | 1931 | 27.43 |
| … | … | … | … |
chart(barley) .flow( spread(by="site", dir="x") )
chart(barley) .flow( spread(by="site", dir="x") ) .mark( rect(h="yield") )
chart(barley) .flow( spread(by="site", dir="x") ) .mark( rect(h="yield") )
aggregate yield by site
chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x") ) .mark( rect(h="yield", fill="site") )
group_by("site")
group_by("year")
h = sum(yield)
fill = first(site)
chart(barley).flow( spread(by="site", dir="x"), spread(by="year", dir="x"), ).mark(rect(h="yield", fill="site"))
chart(barley).flow( spread(by="site", dir="x"), spread(by="year", dir="x"), ).mark(rect(h="yield", fill="site"))
chart(barley).flow( spread(by="site", dir="x"), spread(by="year", dir="x"), ).mark(rect(h="yield", fill="site"))
chart(barley).flow( spread(by="site", dir="x"), spread(by="year", dir="x"), ).mark(rect(h="yield", fill="site"))
chart(barley).flow( spread(by="year", dir="x"), spread(by="site", dir="x"), ).mark(rect(h="yield", fill="site"))
What are the trends across sites?
spread(by="site", dir="x") spread(by="variety", dir="x")
What is the most productive site for each variety?
spread(by="variety", dir="x") spread(by="site", dir="x")
chart(barley) .flow( spread(by="site", dir="x"), spread(by="variety", dir="x"), spread(by="year", dir="x") ) .mark( rect(h="yield", fill="variety") )
stack()chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x"), stack(by="variety", dir="y", size="yield") ) .mark( rect(w=8, fill="variety") )
chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x"), stack(by="variety", dir="y", size=field("yield").normalize()) ) .mark( rect(w=8, fill="variety") )
chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x"), stack(by=field("variety").sort("yield"), dir="y", size=field("yield").normalize()) ) .mark( rect(w=8, fill="variety") )
chart(barley) .flow( table(by=("site", "year")), ) .mark( chart(coord=clock()) .flow(stack(by="variety", dir="x", size="yield")) .mark(rect(fill="variety")) )
chart(data).flow(
spread(by="lake", dir="x"),
stack(by="species", dir="y", size="count"),
).mark(rect(fill="species"))
chart(data).flow(
table(by=("lake", "species")),
).mark(rect(fill="count"))
sort() on the stackchart(barley) .flow( spread(by="variety", dir="x", spacing=24), spread(by="year", dir="x", spacing=4), stack(by=field("site").sort("yield"), dir="y", size="yield") ) .mark( rect(fill="site") )
chart(barley) .flow( spread(by="variety", dir="x"), spread(by="year", dir="x"), stack(by=field("site").sort("yield"), dir="y", size="yield") ) .mark( rect(w=8, fill="site") )
layer([
chart(barley)
.flow(
spread(by="variety", dir="x", spacing=24),
spread(by="year", dir="x", spacing=16),
stack(by=field("site").sort("yield"), dir="y", size="yield")
)
.mark(rect(w=8, fill="site").name("bars")),
chart(selectAll("bars"))
.flow(group(by="variety"), group(by="site"))
.mark(ribbon(opacity=0.5))
])
layer([
chart(barley, color=palette({
"Waseca": "#d7dadd", "Morris": "#49a66a",
"Grand Rapids": "#d7dadd",
"Crookston": "#d7dadd", "Duluth": "#d7dadd",
}))
.flow(
spread(by="variety", dir="x", spacing=38),
spread(by="year", dir="x", spacing=8),
stack(by=field("site").sort("yield"), dir="y", size="yield")
)
.mark(rect(w=16, fill="site").name("bars")),
chart(selectAll("bars"))
.flow(group(by="variety"), group(by="site"))
.mark(ribbon(opacity=0.78))
])
chart(scatter_data).flow(
scatter(by="lake", x="x", y="y")
).mark(
chart(coord=clock())
.flow(stack(by="species", dir="x", size="count"))
.mark(rect(fill="species"))
)
Outer structure:
scatter() affords "where?" queries.
Inner structure: pie affords "what fraction?" queries.
Queries:
# x, y: each site's Minnesota lon/lat
def site_pie(data):
total_yield = total_yield_for(1931, data[0]["site"])
return (
chart(data[0]["collection"], coord=clock())
.flow(stack(by="variety", dir="x", h=pie_radius(total_yield), size="yield"))
.mark(rect(fill="variety"))
)
chart(barley_scatter_by_year(1931)).flow(
scatter(by="site", x="x", y="y")
).mark(site_pie)
Same construction as the lake scatter-pie: each trial site sits at its real Minnesota location, and each glyph is a pie of that site's variety mix.
New move: pie radius (h)
scales with each site's total yield that year, on
a shared scale across both charts.
Watch Morris:
1931
1932
chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x"), scatter(by="variety", y="yield") ) .mark(circle(r=3, fill="variety"))
chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x"), scatter(by="variety", y="yield"), ) .mark(line(by="variety", along="year", fill="variety"))
Slope gives direction immediately. But comparing how much each variety changed means comparing line angles.
Delta bars put every signed change against the same zero baseline. We give up the two absolute yields and the explicit trajectory.
chart(barley) .flow( spread(by="site", dir="x"), spread(by="year", dir="x"), scatter(by="variety", y="yield"), ) .mark(line(stroke="variety"))
chart(barley) .flow( derive(anchor_to_1931), spread(by="site", dir="x"), spread(by="year", dir="x"), scatter(by="variety", y="delta_from_1931"), ) .mark(line(stroke="variety"))
chart(barley) .flow( spread(by="site", dir="x"), spread(by="variety", dir="x"), derive(lambda rows: [{ **rows[0], "delta": rows[1]["yield"] - rows[0]["yield"] }]), ) .mark(rect(h="delta"))
chart(barley) .flow( derive(delta), spread(by="site", dir="x"), scatter(by="variety", y="delta"), ) .mark(circle(r=4))
chart(barley, color=palette({"Morris": "#e08214"})) .flow( spread(by="site", dir="x"), spread(by="variety", dir="x"), derive(lambda rows: [{ **rows[0], "delta": rows[1]["yield"] - rows[0]["yield"] }]), ) .mark(rect(h="delta", fill="site"))
chart(barley, color=palette({"Morris": "#e08214"})) .flow( spread(by="site", dir="x") .label( field("site").map({ "Morris": "Only Morris rose across every variety" }), position="outset-top", color="#e08214" ), spread(by="variety", dir="x"), derive(lambda rows: [{ **rows[0], "delta": rows[1]["yield"] - rows[0]["yield"] }]), ) .mark(rect(h="delta", fill="site"))
FLOWER_RADIUS = 40
stem_data = [{**d, "x": catch_locations[d["lake"]]["x"]} for d in seafood]
layer([
chart(stem_data)
.flow(scatter(by="lake", x="x"))
.mark(
rect(w=4, h="count", fill=color.green[5])
.name("stems")
),
chart(selectAll("stems"))
.flow(group(by="lake"))
.mark(lambda d: spread(
[
d[0],
layer([
stackX(
[
petal(w=v(r["count"]), fill=v(r["species"]).lighten(0.5))
for r in d[0]["datum"]
],
h=FLOWER_RADIUS, spacing=0,
alignment="start", sharedScale=True,
),
], coord=polar()),
],
dir="y", alignment="middle",
spacing=-FLOWER_RADIUS,
)),
])
def balloon(x, y, scale, colors):
return Frame(
[
ellipse(cx=15, cy=15, w=24, h=30, fill=colors["body"]),
ellipse(cx=12, cy=11, w=7, h=11, fill=colors["highlight"]),
rect(cx=15, cy=32, w=8, h=4,
fill=colors["knot"], rx=3, ry=2),
rect(cx=15, cy=32, w=5, h=2.4,
fill=colors["knot"], rx=2, ry=1),
],
x=x - 15 * scale,
y=y + 27 * scale,
box=True,
transform={"scale": {"x": scale, "y": -scale}},
)
def balloon_for_sample(sample):
top3 = sorted(
sample["collection"],
key=lambda r: r["count"],
reverse=True,
)[:3]
return Frame([
rect(x=0, y=0, w=1, h=sample["y"], emY=True),
balloon(0, sample["y"], 1, {
"body": species_color_map[top3[0]["species"]],
"highlight": species_color_map[top3[1]["species"]],
"knot": species_color_map[top3[2]["species"]],
}),
], x=sample["x"])
Frame(
[balloon_for_sample(sample) for sample in scatter_by_lake],
coord=wavy(), x=0, y=0,
)
body, highlight, and
knot channels.
We took one dataset and made a series of deliberate structural moves.
Each move changed what was easy or hard to see.
We didn't have to pick from a menu of charts... though those are useful shorthands for common structures!
Instead, chart type labels like bar or pie were just structural motifs we encountered along the way.
| What you want to see | Operator | Visual structure |
|---|---|---|
| compare the same measure across discrete groups | spread(by=key) |
|
| see how parts add up to a whole | stack(by=key, size=value) |
|
| see how two measures vary together | scatter(by=key, x=x, y=y) |
|
| follow the same item across positions |
line(by=key) or
ribbon(by=key)
|
|
| look up a value by two discrete keys | table(by=(row, col)) |
|
Beyond layout: data transforms change the values, color changes grouping and emphasis, and coordinate transforms change the geometry.
| Operator | shape |
index
what by= builds
|
ask the queries it makes cheap |
|---|---|---|---|
spread(by=key)
|
1 axis · discrete
|
key → its own slot, side by side
|
look up ⟨an entity⟩ at its key
compare ⟨summaries⟩ across key
|
stack(by=key, size=value)
|
1 axis · discrete · slots sized by value
|
key → a segment sized by value, tiled end to end in order
|
look up ⟨key's segment⟩ in order
compare ⟨sizes⟩ across key
read ⟨the total = Σ size⟩ at the far edge
|
scatter(by=key, x=x, y=y)
|
1–2 axes · continuous
|
key → a point at (x, y)
|
look up ⟨an entity⟩ at (x, y)
collect ⟨the shape of the crowd⟩
|
table(by=(row, col))
|
2 axes · discrete
|
(row, col) → a cell, a grid both ways
|
look up ⟨a value⟩ at (row, col)
compare ⟨…⟩ along a row or a column
|
| relational, draws over an index, builds none | |||
line() / ribbon()
|
no index, draws over one
|
none |
follow ⟨an entity⟩ across scopes
read ⟨crossings⟩ as overtakes
|
move 1 of 4 · bars → pies → scatter
shares as stacked bars
the same shares as pies
which species dominates each lake?
size → units
the same counts as continuous bars
how many fish, exactly?
continuous bars
chart(seafood) .flow( spread(by="lake", dir="x"), stack(by="species", dir="y", size="count") ) .mark(rect(w=36, fill="species"))
countable units
chart(seafood) .flow( spread(by="lake", dir="x"), ) .mark(waffle(count="count", fill="species"))
waffle mark
def waffle(count, fill, columns=5): return flow( repeat(count), chunk(columns), spread(dir="y", reverse=True), spread(dir="x"), ).mark(rect(w=8, h=8, fill=fill))
one more level
did third class fare better than crew?
condition on sex: the answer flips
one level
chart(titanic) .flow( stack(by="class", dir="y", size=field("count").normalize()), stack(by="survived", dir="x", size=field("count").normalize()) ) .mark(rect(fill="survived"))
one more level
chart(titanic) .flow( stack(by="class", dir="y", size=field("count").normalize()), stack(by="sex", dir="x", size=field("count").normalize()), stack(by="survived", dir="y", size=field("count").normalize()) ) .mark(rect(fill="survived"))
sort + connect
each year's stack, sorted
the same stacks, connected
which source overtook which?
sort each year's stack
sorted = chart(energyMix) .flow(spread(by="year", dir="x"), stack(by=field("source").sort("amount"), dir="y")) .mark(rect(h="amount", fill="source"))
connect the stacks
area = chart(energyMix) .flow(spread(by="year"), stack(by="source")) .mark(ribbon(by="source", h="amount", fill="source", opacity=0.8))
sort + connect
chart(energyMix) .flow( spread(by="year", dir="x"), stack(by=field("source").sort("amount"), dir="y") ) .mark(rect(h="amount", fill="source")) .layer(ribbon(by="source", opacity=0.5))
UK electricity generation · Our World in Data / Ember
pictorial charts
ordinary normalized bars
paint the bars as bottles
layer on labels
normalized bars
chart(bottleData, axes=False) .flow(spread(by="category", dir="x", spacing=20)) .mark(layer([ rect(h=datum(100), fill="gray"), rect(h="amount", fill="green"), ]))
paint the bottle
chart(bottleData, axes=False) .flow(spread(by="category", dir="x", spacing=20)) .mark(paint([ image(href=bottlePng, h=datum(100)), rect(h="amount", w=175, fill="green"), ], blendMode="color"))
layer on labels
chart(bottleData, axes=False) .flow(spread(by="category", dir="x", spacing=20)) .mark( layer([ paint([ image(href=bottlePng, h=datum(100)), rect(h="amount", w=175, fill="green"), ], blendMode="color").name("bottle"), rect(h=1, y="amount", fill="gray").name("line"), text(text="{amount}%", fill="gray").name("label"), ]).constrain(lambda bottle, line, label: [ Constraint.align([bottle, line], x="start"), Constraint.distribute([line, label], dir="y", spacing=0), Constraint.align([label, line], x="end"), ]) )
Pictorial composition grammar adapted from PiCCL · Shi et al., IEEE VIS 2025
chart(titanic) .flow( stack(by="class", dir="y", size=field("count").normalize()), stack(by="sex", dir="x", size=field("count").normalize()), stack(by="survived", dir="y", size=field("count").normalize()) ) .mark( rect(fill="survived") )
Why "more"
The Grammar of Graphics fits inside GoFish, and so do grammars beyond it.
GoFish's own test gallery reimplements, example by example:
1. Graphics are visual data structures that we query with our eyes.
2. GoFish makes visual structure composable, letting us build familiar charts and continue beyond what conventional visualization grammars make easy.
3. Start with what the reader needs to see, then build the structure that makes it easy.
Python alpha release is here!
🌐 Docs:
gofish.graphics
🐙 GitHub:
gofish-graphics/gofish-graphics
🎞️ Slides:
gofish-graphics/scipy-2026-talk
I’m defending my PhD in a week and a half(!) I don’t know exactly what comes next, but I want to work with scientists, mathematicians, and software engineers on challenging visualization design problems. I also want to maintain GoFish as a system that bridges visualization research and practice.