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_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()