plotlet v0.6.2

4. Multi-panel composition

Operators, grids, shared scales, attachments, insets and facets — the layer the flagship figures are built on. Every snippet on this page is executed on every build — the figures are its output.

Operators and grids

Charts are values: a | b puts panels side by side, a / b stacks them, and pt.grid([[...]]) builds full grids — None leaves a cell empty. Margins are coordinated across the layout so data regions align.

import math
xs = [i * 0.2 for i in range(40)]
sig = {"x": xs, "y": [math.sin(x) for x in xs]}
counts = {"cat": ["a", "b", "c"], "n": [4, 7, 3]}
dist = {"x": [0.1, 0.4, 0.5, 0.55, 0.7, 0.8, 0.9, 1.1, 1.3]}

a = pt.chart(sig, aes(x="x", y="y"), title="signal",
             data_width=220, data_height=140)
a.add_line()
b = pt.chart(counts, aes(x="cat", y="n"), title="counts",
             data_width=220, data_height=140)
b.add_bar()
d = pt.chart(dist, aes(x="x"), title="dist",
             data_width=220, data_height=140)
d.add_hist(bins=6)
c = pt.grid([[a, b], [d, None]])   # same as (a | b) / bottom row
["a","b","c"]

Panel sizes and gaps

Layouts are body-first: each chart’s data_width / data_height act as relative size hints, and the layout sums them. .gap(0) removes the default spacing; .heights([...]) overrides row ratios.

import math
xs = [i * 0.15 for i in range(60)]
sig = {"x": xs, "y": [math.sin(x) for x in xs]}
rate = {"x": xs, "y": [math.cos(3 * x) for x in xs]}

main = pt.chart(sig, aes(x="x", y="y"),
                data_width=420, data_height=180, ylabel="signal")
main.add_line()
small = pt.chart(rate, aes(x="x", y="y"),
                 data_width=420, data_height=60, ylabel="rate")
small.add_line(color="C2")
c = pt.grid([[small], [main]]).gap(0).share_x("col")

Shared scales

.share_x() / .share_y() take "col", "row", or True for all panels: data ranges union, panels align, inner tick labels collapse. This is what makes Anscombe grids and stacked tracks honest — every panel really is on the same scale.

raw = pt.load_dataset("anscombe")

panels = []
for ds in ("I", "II", "III", "IV"):
    sub = {"x": [x for x, d in zip(raw["x"], raw["dataset"])
                 if d == ds],
           "y": [y for y, d in zip(raw["y"], raw["dataset"])
                 if d == ds]}
    p = pt.chart(sub, aes(x="x", y="y"), title=f"dataset {ds}",
                 data_width=180, data_height=130)
    p.add_scatter(size=3)
    p.add_regression()
    panels.append(p)
a, b, d, e = panels
c = pt.grid([[a, b], [d, e]]).share_x(True).share_y(True)

Attachments

attach_above / _below / _left / _right glue annotation tracks to a host chart and share the touching axis — marginal histograms, annotation strips, dendrograms. Multiple attachments stack outward in call order.

import random
rng = random.Random(5)
xs = [rng.gauss(0, 1) for _ in range(300)]
df = {"x": xs, "y": [x * 0.6 + rng.gauss(0, 0.6) for x in xs]}

c = pt.chart(df, aes(x="x", y="y"), data_width=300, data_height=200,
             xlabel="x", ylabel="y")
c.add_scatter(size=2, alpha=0.5)
top = pt.chart(df, aes(x="x"), data_height=55)
top.add_hist(bins=30, fill="#8a8fb0")
c.attach_above(top)

Insets

c.inset(rect=(x, y, w, h)) places a child panel inside the parent’s data area (fractions of the panel), with its own limits — the classic zoom-on-the-tail.

labels = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]
counts = [950, 320, 80, 45, 28, 18, 12, 8, 5, 3]
df = {"category": labels, "count": counts}
df_tail = {"category": labels[2:], "count": counts[2:]}

c = pt.chart(df, aes(x="category", y="count"),
             data_width=440, data_height=240,
             title="long-tail distribution", ylabel="count")
c.add_bar()
inset = c.inset(rect=(0.4, 0.45, 0.55, 0.45), ylim=(0, 100))
inset.add_bar(df_tail, aes(x="category", y="count"))
["A","B","C","D","E","F","G","H","I","J"]["C","D","E","F","G","H","I","J"]

Facets

When the panels are one-per-group over the same columns, skip manual loops: pt.facet(df, by=...) splits a table into a wrapped grid of shared-axis panels, and row= / col= build two-factor grids.

import math
raw = pt.load_dataset("penguins")
keep = [i for i in range(len(raw["species"]))
        if not math.isnan(raw["bill_length_mm"][i])
        and not math.isnan(raw["bill_depth_mm"][i])]
df = {k: [raw[k][i] for i in keep]
      for k in ("species", "bill_length_mm", "bill_depth_mm")}

c = pt.facet(df, by="species", col_wrap=3,
             data_width=170, data_height=140,
             xlabel="bill length (mm)", ylabel="bill depth (mm)")
c.add_scatter(aes(x="bill_length_mm", y="bill_depth_mm"),
              size=2, alpha=0.7)

← Titles, labels & legends  ·  Sectors & circular coordinates →