agentleFS
Registry

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.

Mount
scripts/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()