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_stack.py
#!/usr/bin/env python3
"""A stacked-area (or stream) chart over periods, Tufte style: every series a band, coloured only
if its share changed.
Usage:
python3 draw_stack.py --spec SPEC.json --out chart.html [--svg chart.svg] [--paper]
The spec holds the wording and points at the data ("data": "file.json", relative to the spec, or
the data object inline). Data:
{"q": ["2025Q2", ...], periods: 2026Q1, 2026-03, 2026, 2026-03-15, or any labels
"groups": [{"name", "v": [...], "kind": "rise"|"fall"|"steady"|"too few" (optional)}, ...],
bottom to top
"tot": [...] (optional; default the sum of the groups and rest)
"nfiles": [...] (optional; counts printed under the axis; a bin with 0 is skipped)
"from_q", "to_q" (optional; the two bins compared, default first and last)
"rest": [...] (optional; part of the total left undrawn, marked by a dashed line)}
A group without a kind gets one from its share of the total, from_q -> to_q, with the spec's
"threshold" (default 0.15): rise above +threshold, fall below -threshold, else steady.
Spec keys (all text comes from here; none is built in):
title, aria, subtitle [lines], key (colour key sentence; [red]..[/] and [blue]..[/] colour words),
ylabel, share_label, quote {text, date, mark}, events [{date, label}], event_anchor
{label: [anchor, dx]}, today, arrow [lines] (omit for no arrow), footnote [lines],
count_label (word left of the counts), threshold, y_step (tick spacing), baseline
("zero" default, or "center" for a stream chart: no y axis), layout {width, height, x0, x1,
y_bottom, plot_height (560: the stack's top is fitted to this many px), max_scale (optional
cap in px per unit, e.g. 40 to keep small totals from filling the plot)}, period_labels [..],
value_format (".1f").
{placeholders} in any text: total_from, total_to, change_pct, p_from, p_to, n_total (and the
older names tot_from, tot_to, q_from, q_to, files). Negative numbers print with a true minus.
Bins sit at their centres; between them a monotone cubic, which never overshoots a measured
value; held flat out to the outer bin edges.
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 datetime import date
from tufte import (BLUE, FOOT, GREYS, INK, PAPER, RED, Axis, add_background_arg, background, esc, events,
filler, load_spec, num, parse_events, smooth_path, spread, text_width, write)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--spec", required=True)
ap.add_argument("--out", required=True, help="HTML page with the inline SVG")
add_background_arg(ap)
ap.add_argument("--svg", help="also write the bare SVG here")
a = ap.parse_args()
T = load_spec(a.spec)
D = T["data"]
L = T.get("layout", {})
Q = D["q"]
n = len(Q)
nfiles = D.get("nfiles")
# bins with no files (or no values) are skipped: never drawn as 0
live = [i for i in range(n) if (not nfiles or nfiles[i]) and all(g["v"][i] is not None for g in D["groups"])]
z = lambda x: 0.0 if x is None else x
groups = [{**g, "v": [z(x) for x in g["v"]]} for g in D["groups"]]
rest = [z(x) for x in (D.get("rest") or [0.0] * n)]
tot = [z(x) for x in D["tot"]] if D.get("tot") else [rest[i] + sum(g["v"][i] for g in groups) for i in range(n)]
if len(live) < 2:
raise SystemExit("need at least two bins with data")
qf, qt = D.get("from_q", Q[live[0]]), D.get("to_q", Q[live[-1]])
i0, i1 = Q.index(qf), Q.index(qt)
thr = T.get("threshold", D.get("threshold", 0.15))
# an empty compared bin has no share and no change: shares are null, kinds "too few", no arrow
ends_ok = i0 in live and i1 in live
if not ends_ok:
print(f"note: the compared bin {qf if i0 not in live else qt} is empty; no arrow, shares shown as –")
for g in groups:
g.setdefault("share_from", g["v"][i0] / tot[i0] if tot[i0] else 0.0)
g.setdefault("share_to", g["v"][i1] / tot[i1] if tot[i1] else 0.0)
if i0 not in live:
g["share_from"] = None
if i1 not in live:
g["share_to"] = None
if g["share_from"] is None or g["share_to"] is None:
g["kind"] = "too few"
elif "kind" not in g:
s0, s1 = g["share_from"], g["share_to"]
ch = (s1 / s0 - 1) if s0 else (1.0 if s1 > 0 else 0.0)
g["kind"] = "rise" if ch > thr else "fall" if ch < -thr else "steady"
labels = T.get("period_labels")
vals = {
"total_from": tot[i0] if i0 in live else float("nan"), "total_to": tot[i1] if i1 in live else float("nan"),
"change_pct": (tot[i1] / tot[i0] - 1) * 100 if ends_ok and tot[i0] else float("nan"),
"n_total": sum(nfiles) if nfiles else 0, "data": D,
}
ax0 = Axis(Q, 0, 1)
vals["p_from"] = labels[i0] if labels else ax0.labels[i0]
vals["p_to"] = labels[i1] if labels else ax0.labels[i1]
vals.update(tot_from=vals["total_from"], tot_to=vals["total_to"], q_from=vals["p_from"], q_to=vals["p_to"], files=vals["n_total"])
fill = filler(vals)
vf = T.get("value_format", ".1f")
W, H = L.get("width", 1200), L.get("height", 1040)
X0, X1, YB = L.get("x0", 70), L.get("x1", 790), L.get("y_bottom", 850)
center = T.get("baseline", "zero") == "center"
top_tot = max(rest[i] + sum(g["v"][i] for g in groups) for i in live)
ymax = int(top_tot) + 1
# the stack fills plot_height (560 px) whatever its units; layout.max_scale caps px per unit
cap = L.get("max_scale") or float("inf")
S = min(cap, L.get("plot_height", 560.0) / ymax)
if T.get("arrow") and ends_ok and not center and tot[i1] > tot[i0]:
# a rising arrow curls up to ~70 px above the stack: give the y axis room for it
ymax = max(ymax, int(tot[i1] + 72 / S) + 1)
S = min(cap, L.get("plot_height", 560.0) / ymax)
yv = lambda v: YB - v * S
ax = Axis(Q, X0, X1)
xs, ML, MR = ax.mid, ax.left, ax.right
plab = labels or ax.labels
def curve(ys, reverse=False, start="M"):
pts = [(ML[live[0]], ys[live[0]])] + [(xs[i], ys[i]) for i in live] + [(MR[live[-1]], ys[live[-1]])]
return smooth_path(pts, reverse, start)
svg = [f'<svg id="fig" viewBox="0 0 {W} {H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="{esc(fill(T.get("aria", T.get("title", ""))))}">',
'<defs><marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M1,1 L9,5 L1,9" fill="none" stroke="#111" stroke-width="1.3"/></marker></defs>',
f'<rect width="{W}" height="{H}" fill="{PAPER}"/>']
if T.get("title"):
svg.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", [])):
svg.append(f'<text x="{X0}" y="{84 + 22 * k}" font-size="17" fill="#222">{esc(fill(line))}</text>')
if T.get("key"):
key = esc(fill(T["key"])).replace("[red]", f'<tspan fill="{RED}" font-weight="600">').replace("[blue]", f'<tspan fill="{BLUE}" font-weight="600">').replace("[/]", "</tspan>")
svg.append(f'<text x="{X0}" y="134" font-size="15" fill="#444">{key}</text>')
header_bottom = max([60 if T.get("title") else 0] + [84 + 22 * k for k in range(len(T.get("subtitle", [])))] + ([134] if T.get("key") else []))
hdr_end = len(svg)
quote = T.get("quote")
if quote:
bx = ax.date_x(date.fromisoformat(quote["date"]))
mark = f'<tspan dy="-8" font-size="12">{esc(quote["mark"])}</tspan>' if quote.get("mark") else ""
svg.append(f'<text x="{bx + 10:.1f}" y="186" font-size="18" fill="{RED}">{esc(quote["text"])}{mark}</text>')
if T.get("events"):
events(svg, ax.date_x, parse_events(T), 252, YB, {k: tuple(v) for k, v in T.get("event_anchor", {}).items()})
# bands, bottom to top; a stream chart centres the stack on a middle line
mid_y = YB - ymax * S / 2
if center:
edge = [mid_y + (tot[i] * S) / 2 for i in range(n)]
else:
edge = [yv(r) for r in rest]
ends = []
for k, g in enumerate(groups):
top = [edge[i] - g["v"][i] * S for i in range(n)]
kind = g["kind"]
if kind == "rise":
col, op = RED, f"{0.40 + 0.08 * (k % 3):.2f}"
elif kind == "fall":
col, op = BLUE, f"{0.38 + 0.08 * (k % 3):.2f}"
else:
col, op = GREYS[k % 2], "1"
svg.append(f'<path d="{curve(edge)} {curve(top, True, "L")} Z" fill="{col}" fill-opacity="{op}" stroke="{PAPER}" stroke-width="1"/>')
e = live[-1]
ends.append([g["name"], (edge[e] + top[e]) / 2, g["share_from"], g["share_to"], kind])
edge = top
if not center and any(r > 1e-9 for r in rest):
svg.append(f'<path d="{curve([yv(r) for r in rest])}" fill="none" stroke="#999" stroke-width=".7" stroke-dasharray="3 3"/>')
# y axis: a range frame (none for a stream chart, whose height has no zero)
if not center:
step = T.get("y_step", 2)
svg.append(f'<line x1="{X0 - 8}" x2="{X0 - 8}" y1="{yv(ymax):.1f}" y2="{YB}" stroke="#111" stroke-width=".6"/>')
for v in range(0, ymax + 1, step):
svg.append(f'<line x1="{X0 - 12}" x2="{X0 - 8}" y1="{yv(v):.1f}" y2="{yv(v):.1f}" stroke="#111" stroke-width=".6"/>')
svg.append(f'<text x="{X0 - 16}" y="{yv(v) + 4:.1f}" font-size="12.5" text-anchor="end" fill="#555">{v}</text>')
if T.get("ylabel"):
svg.append(f'<text x="{X0 - 60}" y="{yv(ymax) - 14:.1f}" font-size="13.5" fill="#333">{esc(fill(T["ylabel"]))}</text>')
elif T.get("ylabel"):
svg.append(f'<text x="{X0}" y="{yv(ymax) - 14:.1f}" font-size="13.5" fill="#333">{esc(fill(T["ylabel"]))}</text>')
# x axis: bin edges as ticks, the period at each bin centre, counts beneath
svg.append(f'<line x1="{X0}" x2="{X1}" y1="{YB + 8}" y2="{YB + 8}" stroke="#111" stroke-width=".7"/>')
for i, q in enumerate(Q):
cx = (ML[i] + MR[i]) / 2
svg.append(f'<line x1="{ML[i]:.1f}" x2="{ML[i]:.1f}" y1="{YB + 8}" y2="{YB + 14}" stroke="#111" stroke-width=".6"/>')
svg.append(f'<text x="{cx:.1f}" y="{YB + 30}" font-size="13.5" text-anchor="middle" fill="#333">{esc(plab[i])}</text>')
if nfiles:
svg.append(f'<text x="{cx:.1f}" y="{YB + 47}" font-size="11" text-anchor="middle" fill="#999">{nfiles[i]:,}</text>')
svg.append(f'<line x1="{MR[-1]:.1f}" x2="{MR[-1]:.1f}" y1="{YB + 8}" y2="{YB + 14}" stroke="#111" stroke-width=".6"/>')
if nfiles and T.get("count_label"):
svg.append(f'<text x="{X0 - 16:.1f}" y="{YB + 47}" font-size="11" text-anchor="end" fill="#999">{esc(T["count_label"])}</text>')
if T.get("today"):
svg.append(f'<text x="{X1:.1f}" y="{YB + 68}" font-size="12" text-anchor="end" fill="#888">{esc(T["today"])}</text>')
# direct labels at the right edge, leader curves to each band, pushed apart to stay legible
ends.sort(key=lambda e: e[1])
LX = X1 + 70
show = T.get("show_shares", True)
pct = lambda x: "–" if x is None else f"{num(x * 100, vf)}%"
share_txt = lambda e: f"{pct(e[2])} → {pct(e[3])}"
# measure the label column: if a name would run into its share values, put the values on a
# second line under the name instead
# (text_width is within about 10% of ET Book's real widths; switch only on a clear overlap)
two = show and any(LX + 0.94 * text_width(e[0], 14.5) + 2 > W - 20 - text_width(share_txt(e), 14) for e in ends)
room = W - 20 - LX
def wrap(name): # names wider than the column go on two or more lines
lines, cur = [], ""
for word in name.split():
t = (cur + " " + word).strip()
if cur and 0.94 * text_width(t, 14.5) > room:
lines.append(cur)
cur = word
else:
cur = t
return lines + [cur]
wrapped = [wrap(e[0]) for e in ends]
extra = max(len(w) for w in wrapped) - 1
if extra:
two = show
pos = spread([e[1] for e in ends], (36 if two else 24.5) + 17 * extra, 0, YB - 4)
for (name, yb, s0, s1, kind), y, lines in zip(ends, pos, wrapped):
col = RED if kind == "rise" else BLUE if kind == "fall" else "#555"
w = ' font-weight="600"' if kind in ("rise", "fall") else ""
svg.append(f'<path d="M{X1 + 2},{yb:.1f} C{X1 + 36},{yb:.1f} {X1 + 30},{y - 4:.1f} {LX - 6},{y - 4:.1f}" fill="none" stroke="{col}" stroke-opacity=".7" stroke-width=".7"/>')
for j, line in enumerate(lines):
svg.append(f'<text x="{LX}" y="{y + 17 * j:.1f}" font-size="14.5" fill="{col}"{w}>{esc(line)}</text>')
if show and two:
svg.append(f'<text x="{LX}" y="{y + 17 * (len(lines) - 1) + 15:.1f}" font-size="12.5" fill="{col}">{share_txt((name, yb, s0, s1))}</text>')
elif show:
svg.append(f'<text x="{W - 20}" y="{y:.1f}" font-size="14" text-anchor="end" fill="{col}">{share_txt((name, yb, s0, s1))}</text>')
if show and T.get("share_label"):
sx, anchor = (LX, "start") if two else (W - 20, "end")
svg.append(f'<text x="{sx}" y="{min(pos) - 28:.1f}" font-size="12.5" text-anchor="{anchor}" fill="#555">{esc(fill(T["share_label"]))}</text>')
if any(g["kind"] == "too few" for g in groups) and T.get("too_few_note"):
svg.append(f'<text x="{W - 20}" y="{pos[-1] + 26:.1f}" font-size="12" text-anchor="end" fill="#888">{esc(fill(T["too_few_note"]))}</text>')
if quote:
svg.append(f'<line x1="{bx:.1f}" x2="{bx:.1f}" y1="172" y2="{YB}" stroke="{RED}" stroke-width="1.8"/>')
# the one annotation: an arrow over the total, from the first to the last compared bin
arrow_top = None
if T.get("arrow") and ends_ok:
top_of = (lambda i: mid_y - tot[i] * S / 2) if center else (lambda i: yv(tot[i]))
xa, ya = xs[i0], top_of(i0) - 34
xb, yb2 = xs[i1], top_of(i1) - 34
svg.append(f'<path d="M{xa:.1f},{ya:.1f} C{xa + (xb - xa) * 0.35:.1f},{ya - 4:.1f} {xa + (xb - xa) * 0.65:.1f},{yb2 - 30:.1f} {xb:.1f},{yb2:.1f}" fill="none" stroke="#111" stroke-width=".9" marker-end="url(#ah)"/>')
rising = yb2 < ya - 1
# keep the words clear of the bands: lift them if the stack top under them is too high
tx0 = xa + (xb - xa) * 0.52
tx1 = tx0 + max(text_width(fill(l), 13) for l in T["arrow"])
under = [top_of(i) for i in live if tx0 - 30 <= xs[i] <= tx1 + 30]
bottom = (ya + yb2) / 2 - 40 + (len(T["arrow"]) - 1) * 16 + 4
lift = max(0.0, bottom - (min(under) - 8)) if under and not rising else 0.0
arrow_top = min(ya - 4, yb2 - 30) - 6
for k, line in enumerate(T["arrow"]):
if rising: # the curve climbs to the right: put the words above its left end, clear of the line
ty = ya - 30 - 16 * (len(T["arrow"]) - 1 - k)
svg.append(f'<text x="{xa:.1f}" y="{ty:.1f}" font-size="13" fill="#222">{esc(fill(line))}</text>')
arrow_top = min(arrow_top, ty - 14)
else:
svg.append(f'<text x="{xa + (xb - xa) * 0.52:.1f}" y="{(ya + yb2) / 2 - 40 + k * 16 - lift:.1f}" font-size="13" fill="#222">{esc(fill(line))}</text>')
arrow_top = min(arrow_top, (ya + yb2) / 2 - 40 - lift - 14)
# with no events and no quote, the row kept for them would be an empty band: close it up
if not T.get("events") and not quote and not L.get("keep_event_row"):
content_top = min(yv(ymax) - 30, min(pos) - 42, arrow_top if arrow_top is not None else 1e9)
d = int(content_top - (header_bottom + 36))
if d > 0:
svg.insert(hdr_end, f'<g transform="translate(0,{-d})">')
svg.append("</g>")
H -= d
svg[0] = svg[0].replace(f'viewBox="0 0 {W} {H + d}"', f'viewBox="0 0 {W} {H}"')
svg[2] = svg[2].replace(f'height="{H + d}"', f'height="{H}"')
for k, line in enumerate(T.get("footnote", [])):
svg.append(f'<text x="{X0}" y="{H - 70 + k * 17}" font-size="12" font-style="italic" fill="{FOOT}">{esc(fill(line))}</text>')
svg.append("</svg>")
write(a.out, a.svg, svg, W, bg=background(T, a))
print(f"wrote {a.out} stack top {top_tot:.2f}, {S:.1f}px per unit, {len(live)} bins with data")
if __name__ == "__main__":
main()

