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_slope.py
#!/usr/bin/env python3
"""A slopegraph, Tufte style: each item's value at two points, joined by a line, labelled on both
sides, with a thin leader from each end to its label (labels of tied values are pushed apart).
Colour only what changed.
Usage:
python3 draw_slope.py --spec SPEC.json --out chart.html [--svg chart.svg] [--paper]
Data (the spec's "data", a file beside it or inline), either:
{"items": [{"name", "from": 12.5, "to": 17.3, "kind"?: "rise"|"fall"|"steady"}, ...]}
or stacked data as draw_stack.py reads it ({"q", "groups": [{"name", "v"}], ...}); then the spec's
"left" and "right" name the two periods, and "measure" is "value" (the group's v) or "share"
(percent of the period's total, the default).
Spec keys: title, subtitle [lines], left_label, right_label (column heads; {left} and {right} are
the period names), value_format ("{:.1f}%" for shares, "{:.1f}" for values), threshold (0.15:
relative change that counts as rise or fall, used when an item has no kind), footnote [lines],
layout {width, x_left, x_right, row (min px between labels), top, plot_height}.
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, spread, write)
def items_of(T):
D = T["data"]
if isinstance(D.get("items"), list):
return [dict(i) for i in D["items"]], {}
Q, groups = D["q"], D["groups"]
left, right = T.get("left", D.get("from_q", Q[0])), T.get("right", D.get("to_q", Q[-1]))
for q in (left, right):
if q not in Q:
raise SystemExit(f"period {q!r} is not in the data's q: {', '.join(Q)}")
i0, i1 = Q.index(left), Q.index(right)
nf = D.get("nfiles")
for i, q in ((i0, left), (i1, right)):
empty = (nf and not nf[i]) or any(g["v"][i] is None for g in groups) or (D.get("tot") and D["tot"][i] is None)
if empty:
raise SystemExit(f"the {q} bin is empty, so there is nothing to compare; choose other \"left\"/\"right\" periods")
tot = D.get("tot") or [sum(g["v"][i] for g in groups) + (D.get("rest") or [0] * len(Q))[i] for i in range(len(Q))]
share = T.get("measure", "share") == "share"
out = []
for g in groups[::-1]: # top of the stack first
a, b = g["v"][i0], g["v"][i1]
if share:
a, b = 100 * a / tot[i0], 100 * b / tot[i1]
it = {"name": g["name"], "from": a, "to": b}
if g.get("kind") in ("rise", "fall", "steady", "too few"):
it["kind"] = g["kind"]
out.append(it)
ax = Axis(Q, 0, 1)
return out, {"left": ax.labels[i0], "right": ax.labels[i1]}
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)
L = T.get("layout", {})
items, names = items_of(T)
fill = filler(names)
thr = T.get("threshold", 0.15)
vf = T.get("value_format", "{:.1f}%" if not isinstance(T["data"].get("items"), list) and T.get("measure", "share") == "share" else "{:.1f}")
for it in items:
if "kind" not in it:
ch = (it["to"] / it["from"] - 1) if it["from"] else (1.0 if it["to"] > 0 else 0.0)
it["kind"] = "rise" if ch > thr else "fall" if ch < -thr else "steady"
W = L.get("width", 1000)
XL, XR = L.get("x_left", 380), L.get("x_right", 620)
TOP = L.get("top", 170)
PH = L.get("plot_height", max(300, 26 * len(items)))
row = L.get("row", 22)
vmax = max(max(i["from"], i["to"]) for i in items)
vmin = min(min(i["from"], i["to"]) for i in items)
vmin = min(0.0, vmin)
yv = lambda v: TOP + PH - (v - vmin) / (vmax - vmin or 1) * PH
ly = spread([yv(i["from"]) for i in items], row, TOP, TOP + PH + 40)
ry = spread([yv(i["to"]) for i in items], row, TOP, TOP + PH + 40)
H = int(max(max(ly), max(ry)) + 60 + 18 * len(T.get("footnote", [])))
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="40" 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="40" y="{84 + 22 * k}" font-size="17" fill="#222">{esc(fill(line))}</text>')
for x, key, anchor in ((XL, "left_label", "end"), (XR, "right_label", "start")):
o.append(f'<text x="{x}" y="{TOP - 30}" font-size="15" text-anchor="{anchor}" fill="#333">{esc(fill(T.get(key, "{" + key.split("_")[0] + "}")))}</text>')
for it, y0, y1 in zip(items, ly, ry):
col = RED if it["kind"] == "rise" else BLUE if it["kind"] == "fall" else "#999"
txt = RED if it["kind"] == "rise" else BLUE if it["kind"] == "fall" else "#555"
w = ' font-weight="600"' if it["kind"] in ("rise", "fall") else ""
a0, a1 = yv(it["from"]), yv(it["to"])
o.append(f'<line x1="{XL + 30}" x2="{XR - 30}" y1="{a0:.1f}" y2="{a1:.1f}" stroke="{col}" stroke-width="{1.6 if w else 1}"/>')
# a thin leader from each end of the line to its label, which may have been pushed apart from a tie
o.append(f'<path d="M{XL + 2},{y0:.1f} C{XL + 16},{y0:.1f} {XL + 14},{a0:.1f} {XL + 30},{a0:.1f}" fill="none" stroke="{col}" stroke-opacity=".7" stroke-width=".7"/>')
o.append(f'<path d="M{XR - 30},{a1:.1f} C{XR - 14},{a1:.1f} {XR - 16},{y1:.1f} {XR - 2},{y1:.1f}" fill="none" stroke="{col}" stroke-opacity=".7" stroke-width=".7"/>')
o.append(f'<text x="{XL - 4}" y="{y0 + 5:.1f}" font-size="14" text-anchor="end" fill="{txt}"{w}>{esc(it["name"])} {esc(fmt(vf, it["from"]))}</text>')
o.append(f'<text x="{XR + 4}" y="{y1 + 5:.1f}" font-size="14" fill="{txt}"{w}>{esc(fmt(vf, it["to"]))} {esc(it["name"])}</text>')
for k, line in enumerate(T.get("footnote", [])):
o.append(f'<text x="40" y="{H - 24 - 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(items)} items, " + ", ".join(f"{i['kind']} {sum(1 for x in items if x['kind'] == i['kind'])}" for i in {x['kind']: x for x in items}.values()))
if __name__ == "__main__":
main()

