Tufte-style charts
skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-12
Clean, directly labelled charts from a JSON spec that read in light and dark mode: stacks, lines, slopegraphs, small multiples.
Mountscripts/draw_multiples.py
#!/usr/bin/env python3
"""Small multiples, Tufte style: one small line chart per series, all on the same scale, in a grid.
Dated periods (2026Q1, 2026-03, 2026) sit on a true time scale, so uneven gaps show; other labels
are evenly spaced. A faint tick marks each period.
Each panel prints its first and last value; colour marks only the panels that changed.
Usage:
python3 draw_multiples.py --spec SPEC.json --out chart.html [--svg chart.svg] [--paper]
Data (the spec's "data", a file beside it or inline), either:
{"q": [periods], "series": [{"name", "v": [...], "kind"?}]} values may be null (a gap)
or stacked data as draw_stack.py reads it ({"q", "groups": [{"name", "v", "kind"?}], ...}).
Spec keys: title, subtitle [lines], columns (4), panel {width: 230, height: 110, gap_x: 30,
gap_y: 60}, value_format ("{:.1f}"), y {min: 0, max: auto} (shared by every panel), threshold
(0.15: relative change first -> last that counts as rise or fall, when a series has no kind),
footnote [lines]. Panels follow the data order unless "order" is "change" (largest rise first).
Background: transparent by default, with light and dark palettes switched by prefers-color-scheme
inside the SVG. --paper (or spec "background": "paper") draws the classic #fffff8 page instead,
one palette, as before this option existed.
"""
import argparse
from tufte import (BLUE, FOOT, INK, PAPER, RED, Axis, add_background_arg, background, esc, filler, fmt,
load_spec, write)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--spec", required=True)
ap.add_argument("--out", required=True)
add_background_arg(ap)
ap.add_argument("--svg")
a = ap.parse_args()
T = load_spec(a.spec)
D = T["data"]
Q = D["q"]
series = [dict(s) for s in (D.get("series") or D["groups"][::-1])]
thr = T.get("threshold", 0.15)
for s in series:
known = [v for v in s["v"] if v is not None]
f, l = known[0], known[-1]
s["change"] = (l / f - 1) if f else (1.0 if l > 0 else 0.0)
if s.get("kind") not in ("rise", "fall", "steady", "too few"):
s["kind"] = "rise" if s["change"] > thr else "fall" if s["change"] < -thr else "steady"
if T.get("order") == "change":
series.sort(key=lambda s: -s["change"])
vf = T.get("value_format", "{:.1f}")
P = {"width": 230, "height": 110, "gap_x": 30, "gap_y": 60, **T.get("panel", {})}
cols = T.get("columns", 4)
rows = (len(series) + cols - 1) // cols
Y = T.get("y", {})
lo = Y.get("min", 0)
hi = Y.get("max") or max(v for s in series for v in s["v"] if v is not None)
X0, TOP = 50, 150 + 22 * max(0, len(T.get("subtitle", [])) - 1)
W = X0 * 2 + cols * P["width"] + (cols - 1) * P["gap_x"]
H = TOP + rows * (P["height"] + P["gap_y"]) + 30 + 18 * len(T.get("footnote", []))
fill = filler({"lo": lo, "hi": hi})
o = [f'<svg id="fig" viewBox="0 0 {W} {H}" width="{W}" height="{H}" xmlns="http://www.w3.org/2000/svg">',
f'<rect width="{W}" height="{H}" fill="{PAPER}"/>']
if T.get("title"):
o.append(f'<text x="{X0}" y="52" font-size="31" fill="{INK}">{esc(fill(T["title"]))}</text>')
for k, line in enumerate(T.get("subtitle", [])):
o.append(f'<text x="{X0}" y="{84 + 22 * k}" font-size="17" fill="#222">{esc(fill(line))}</text>')
for n, s in enumerate(series):
r, c = divmod(n, cols)
px = X0 + c * (P["width"] + P["gap_x"])
py = TOP + r * (P["height"] + P["gap_y"])
ax = Axis(Q, px, px + P["width"] - 40, pad_days=0)
yv = lambda v: py + P["height"] - (v - lo) / (hi - lo or 1) * P["height"]
col = RED if s["kind"] == "rise" else BLUE if s["kind"] == "fall" else "#333"
bold = ' font-weight="600"' if col != "#333" else ""
o.append(f'<text x="{px}" y="{py - 14}" font-size="14" fill="{col}"{bold}>{esc(s["name"])}</text>')
o.append(f'<line x1="{px}" x2="{px + P["width"] - 40}" y1="{py + P["height"] + 4}" y2="{py + P["height"] + 4}" stroke="#111" stroke-width=".5"/>')
for xm in ax.mid: # one faint tick per period, on a true time scale
o.append(f'<line x1="{xm:.1f}" x2="{xm:.1f}" y1="{py + P["height"] + 4}" y2="{py + P["height"] + 8}" stroke="#111" stroke-width=".4"/>')
if c == 0: # the shared scale, once per row: a range frame
o.append(f'<line x1="{px - 6}" x2="{px - 6}" y1="{yv(hi):.1f}" y2="{yv(lo):.1f}" stroke="#111" stroke-width=".5"/>')
o.append(f'<text x="{px - 9}" y="{yv(hi) + 4:.1f}" font-size="10.5" text-anchor="end" fill="#777">{esc(fmt(vf, hi))}</text>')
o.append(f'<text x="{px - 9}" y="{yv(lo) + 4:.1f}" font-size="10.5" text-anchor="end" fill="#777">{esc(fmt(vf, lo))}</text>')
runs, cur = [], []
for i, v in enumerate(s["v"]):
if v is None:
runs, cur = (runs + [cur] if cur else runs), []
else:
cur.append(i)
if cur:
runs.append(cur)
for run in runs:
if len(run) > 1:
o.append(f'<polyline points="{" ".join(f"{ax.mid[i]:.1f},{yv(s["v"][i]):.1f}" for i in run)}" fill="none" stroke="{col}" stroke-width="1.5"/>')
known = [i for i, v in enumerate(s["v"]) if v is not None]
# the first value sits above its point, clear of the range frame; the last to its right
for i, anchor, dx, dy in ((known[0], "start", -2, -7), (known[-1], "start", 5, 4)):
o.append(f'<circle cx="{ax.mid[i]:.1f}" cy="{yv(s["v"][i]):.1f}" r="2.2" fill="{col}"/>')
o.append(f'<text x="{ax.mid[i] + dx:.1f}" y="{yv(s["v"][i]) + dy:.1f}" font-size="11.5" text-anchor="{anchor}" fill="{col}">{esc(fmt(vf, s["v"][i]))}</text>')
if r == rows - 1 or n + cols >= len(series):
o.append(f'<text x="{ax.mid[0]:.1f}" y="{py + P["height"] + 20}" font-size="10.5" text-anchor="middle" fill="#999">{esc((T.get("period_labels") or ax.labels)[0])}</text>')
o.append(f'<text x="{ax.mid[-1]:.1f}" y="{py + P["height"] + 20}" font-size="10.5" text-anchor="middle" fill="#999">{esc((T.get("period_labels") or ax.labels)[-1])}</text>')
for k, line in enumerate(T.get("footnote", [])):
o.append(f'<text x="{X0}" y="{H - 20 - 18 * (len(T["footnote"]) - 1 - k)}" font-size="12" font-style="italic" fill="{FOOT}">{esc(fill(line))}</text>')
o.append("</svg>")
write(a.out, a.svg, o, W, svg_font=False, bg=background(T, a))
print(f"wrote {a.out}: {len(series)} panels in {rows} rows, shared scale {fmt(vf, lo)} to {fmt(vf, hi)}")
if __name__ == "__main__":
main()

