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_lines.py
#!/usr/bin/env python3
"""Several series over periods, Tufte style: one line each, every point labelled with its value,
each series named at its right end, all on one y scale.

Usage:
  python3 draw_lines.py --spec SPEC.json --out chart.html [--svg chart.svg] [--paper]

Spec keys (all text comes from here):
  data          a JSON file beside the spec, or an object. Whatever shape; series point into it.
  periods       the periods to draw, e.g. ["2025Q2", ...] (2026Q1, 2026-03, 2026, or any labels)
  series        [{label, color, values | from, counts?}] in drawing order. Values may be null:
                the line breaks there. A series gets its values from:
                  "values": [...]                       one per period
                  "from": {"sample": S, "key": K}       data[S][K], matched to periods by data[S]["Q"]
                  "from": {"key": K}                    data[K] at the top level, matched by data["Q"]
                        or data["q"] (e.g. "tot" or "med" of text-rule-trends' pooled.json)
                  "from": {"sample": S, "share_of": A, "among": [A, B, ...]}
                        100 * A / (A + B + ...) from the counts data[S][A], data[S][B] ...;
                        the counts behind each point become the series' counts
                "color" is a palette name (ink, red, blue, grey) or a palette hex, which follow dark
                mode, or any other CSS colour, kept as given in both modes.
                "counts" (optional) are printed under the axis, joined across the series that
                have them with " · "; series without counts are left out of that line.
  y             {min: 0, max: 100, step: 25, tick: "{:.0f}", reference: null}. tick is the
                format of the y-axis numbers: "{:.0f}" (default) for counts, "{:.0f}%" for shares.
                reference draws a faint dashed line at that value (e.g. 50 for shares).
  value_format  how each point's value is printed, default "{:.0f}" ("{:.0f}%" for shares)
  title, subtitle [lines], ylabel, count_label, quote {text, date, mark}, events [{date, label}],
  event_anchor {label: [anchor, dx]}, footnote [lines], layout {width, height, x0, x1, y_bottom, y_top}
  {placeholders} in subtitle and footnote: {a[i]}, {b[i]}, ... are each series' values (series in
  order, i over the periods drawn), {a_last} the last value that is not null, and {data[...]}
  reaches into the data (e.g. {data[random][history_files]:,}). Negative numbers print with a
  true minus.

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 (FOOT, INK, PAPER, RED, Axis, add_background_arg, background, color, esc, events, filler, fmt,
                    load_spec, parse_events, write)


def series_values(s, D, periods):
    if "values" in s:
        v = list(s["values"])
        return v, s.get("counts")
    f = s["from"]
    d = D[f["sample"]] if "sample" in f else D           # no sample: the data's top level (pooled.json)
    dq = d.get("Q") or d.get("q")
    if dq is None:
        raise SystemExit("series data needs its periods as \"Q\" or \"q\"")
    idx = {q: i for i, q in enumerate(dq)}
    if "key" in f:
        v = [d[f["key"]][idx[q]] if q in idx else None for q in periods]
        return v, s.get("counts")
    cnt = {k: [d[k][idx[q]] if q in idx else 0 for q in periods] for k in f["among"]}
    tot = [sum(cnt[k][i] for k in f["among"]) for i in range(len(periods))]
    v = [cnt[f["share_of"]][i] / tot[i] * 100 if tot[i] else None for i in range(len(periods))]
    return v, tot


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"]
    L = T.get("layout", {})

    P = T.get("periods") or D.get("q") or D.get("Q")
    series = []
    for s in T["series"]:
        v, n = series_values(s, D, P)
        series.append({**s, "color": color(s["color"]), "v": v, "n": n})
    names = "abcdefghijklmnop"
    ph = {}
    for i, s in enumerate(series):
        ph[names[i]] = s["v"]
        known = [x for x in s["v"] if x is not None]
        ph[names[i] + "_last"] = known[-1] if known else float("nan")
    ph["data"] = D
    fill = filler(ph)
    Y = {"min": 0, "max": 100, "step": 25, "tick": "{:.0f}", **T.get("y", {})}
    vfmt = T.get("value_format", "{:.0f}")

    W, H = L.get("width", 1200), L.get("height", 720)
    X0, X1 = L.get("x0", 70), L.get("x1", 790)
    YB, YT = L.get("y_bottom", 560), L.get("y_top", 250)
    ax = Axis(P, X0, X1)
    xs = ax.mid
    lo, hi = Y["min"], Y["max"]
    yv = lambda v: YB - (v - lo) / (hi - lo) * (YB - YT)

    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>')
    header_bottom = max([60] + [84 + 22 * k for k in range(len(T.get("subtitle", [])))])
    hdr_end = len(o)
    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 ""
        o.append(f'<text x="{bx + 10:.1f}" y="150" font-size="18" fill="{RED}">{esc(quote["text"])}{mark}</text>')
    if T.get("events"):
        events(o, ax.date_x, parse_events(T), 214, YB, {k: tuple(v) for k, v in T.get("event_anchor", {}).items()})
    if quote:
        o.append(f'<line x1="{bx:.1f}" x2="{bx:.1f}" y1="136" y2="{YB}" stroke="{RED}" stroke-width="1.8"/>')

    # y axis: a range frame, and an optional faint reference line
    o.append(f'<line x1="{X0 - 8}" x2="{X0 - 8}" y1="{yv(hi):.1f}" y2="{YB}" stroke="#111" stroke-width=".6"/>')
    v = lo
    while v <= hi + 1e-9:
        o.append(f'<line x1="{X0 - 12}" x2="{X0 - 8}" y1="{yv(v):.1f}" y2="{yv(v):.1f}" stroke="#111" stroke-width=".6"/>')
        o.append(f'<text x="{X0 - 16}" y="{yv(v) + 4:.1f}" font-size="12.5" text-anchor="end" fill="#555">{esc(fmt(Y["tick"], v))}</text>')
        v += Y["step"]
    if T.get("ylabel"):
        o.append(f'<text x="{X0 - 40}" y="{yv(hi) - 12:.1f}" font-size="13.5" fill="#333">{esc(T["ylabel"])}</text>')
    if Y.get("reference") is not None:
        r = yv(Y["reference"])
        o.append(f'<line x1="{X0}" x2="{X1}" y1="{r:.1f}" y2="{r:.1f}" stroke="#000" stroke-opacity=".25" stroke-width=".7" stroke-dasharray="4 3"/>')

    for s in series:
        # one polyline per run of known values: a missing period breaks the line
        runs, cur = [], []
        for i, val in enumerate(s["v"]):
            if val is None:
                if cur:
                    runs.append(cur)
                cur = []
            else:
                cur.append(i)
        if cur:
            runs.append(cur)
        for run in runs:
            if len(run) > 1:
                o.append(f'<polyline points="{" ".join(f"{xs[i]:.1f},{yv(s["v"][i]):.1f}" for i in run)}" fill="none" stroke="{s["color"]}" stroke-width="2"/>')
        for i, val in enumerate(s["v"]):
            if val is None:
                continue
            x, y = xs[i], yv(val)
            others = [t["v"][i] for t in series if t is not s and t["v"][i] is not None]
            dy = -11 if all(val >= m for m in others) else 20      # value above the higher line, below the lower
            if y + dy > YB - 4:
                dy = -11                                   # never on the x axis: a value at the floor goes above
            o.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="4.5" fill="{PAPER}"/><circle cx="{x:.1f}" cy="{y:.1f}" r="2.8" fill="{s["color"]}"/>')
            o.append(f'<text x="{x:.1f}" y="{y + dy:.1f}" font-size="13" text-anchor="middle" fill="{s["color"]}" stroke="{PAPER}" stroke-width="5" paint-order="stroke">{esc(fmt(vfmt, val))}</text>')
        last = max((i for i, val in enumerate(s["v"]) if val is not None), default=None)
        if last is not None:
            o.append(f'<text x="{xs[last] + 16:.1f}" y="{yv(s["v"][last]) + 5:.1f}" font-size="16" fill="{s["color"]}">{esc(s["label"])}</text>')

    o.append(f'<line x1="{X0}" x2="{X1}" y1="{YB + 8}" y2="{YB + 8}" stroke="#111" stroke-width=".7"/>')
    for i, q in enumerate(P):
        l = ax.left[i]
        o.append(f'<line x1="{l:.1f}" x2="{l:.1f}" y1="{YB + 8}" y2="{YB + 14}" stroke="#111" stroke-width=".6"/>')
        o.append(f'<text x="{xs[i]:.1f}" y="{YB + 30}" font-size="13.5" text-anchor="middle" fill="#333">{esc((T.get("period_labels") or ax.labels)[i])}</text>')
        if any(s["n"] for s in series):
            counts = " · ".join(format(s["n"][i], ",") for s in series if s["n"])
            o.append(f'<text x="{xs[i]:.1f}" y="{YB + 50}" font-size="12" text-anchor="middle" fill="#999">{counts}</text>')
    o.append(f'<line x1="{ax.right[-1]:.1f}" x2="{ax.right[-1]:.1f}" y1="{YB + 8}" y2="{YB + 14}" stroke="#111" stroke-width=".6"/>')
    if T.get("count_label"):
        o.append(f'<text x="{X1 + 16:.1f}" y="{YB + 50}" font-size="12" fill="#999">{esc(T["count_label"])}</text>')
    # with no events and no quote, close up the empty row kept for them above the plot
    if not T.get("events") and not quote and not L.get("keep_event_row"):
        top = yv(hi) - 12 - 16
        if any(s["v"] and max((v for v in s["v"] if v is not None), default=lo) >= hi - (hi - lo) * 0.05 for s in series):
            top -= 12                                    # a value label may sit above the top tick
        d = int(top - (header_bottom + 36))
        if d > 0:
            o.insert(hdr_end, f'<g transform="translate(0,{-d})">')
            o.append("</g>")
            o[0] = o[0].replace(f'viewBox="0 0 {W} {H}" width="{W}" height="{H}"', f'viewBox="0 0 {W} {H - d}" width="{W}" height="{H - d}"')
            o[1] = o[1].replace(f'height="{H}"', f'height="{H - d}"')
            H -= d
    for k, line in enumerate(T.get("footnote", [])):
        o.append(f'<text x="{X0}" y="{H - 34 + 18 * 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}: " + "; ".join(f"{s['label']} " + ", ".join(fmt(vfmt, v) if v is not None else "-" for v in s["v"]) for s in series))


if __name__ == "__main__":
    main()