← back to La Permits School

make_chart.py

120 lines

#!/usr/bin/env python3
"""
make_chart.py — slide-ready "construction value by year" bar chart (school project).
$0, standard library only. Writes a scalable SVG (no matplotlib, no pip).

The final year (2023) is drawn hatched + labeled "partial" because the source data
ends mid-May 2023 — so the bar must not be read as a full-year decline.

Usage:
  python3 make_chart.py                 # permits.csv -> value_by_year.svg
  python3 make_chart.py --in firms.csv --out chart.svg
Then (macOS, free) rasterize for slides:
  qlmanage -t -s 1600 -o . value_by_year.svg   # -> value_by_year.svg.png
"""

import argparse
import csv
from collections import defaultdict

W, H = 960, 540
ML, MR, MT, MB = 80, 30, 70, 70          # margins
PARTIAL_YEAR = "2023"


def money(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def esc(s):
    return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--in", dest="infile", default="permits.csv")
    ap.add_argument("--out", default="value_by_year.svg")
    args = ap.parse_args()

    by_year = defaultdict(float)
    for r in csv.DictReader(open(args.infile, newline="", encoding="utf-8")):
        y = (r.get("issue_date") or "")[:4]
        if y:
            by_year[y] += money(r.get("valuation"))
    years = sorted(by_year)
    vals = [by_year[y] for y in years]
    if not years:
        print("No data in", args.infile); return

    plot_w = W - ML - MR
    plot_h = H - MT - MB
    vmax = max(vals)
    # round the top gridline up to a clean $ billion
    top = (int(vmax // 1e9) + 1) * 1e9
    n = len(years)
    slot = plot_w / n
    bw = slot * 0.62

    def x(i):
        return ML + slot * i + (slot - bw) / 2

    def y(v):
        return MT + plot_h * (1 - v / top)

    svg = []
    svg.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" font-family="Helvetica,Arial,sans-serif">')
    svg.append(f'<rect width="{W}" height="{H}" fill="white"/>')
    # hatch pattern for the partial year
    svg.append('<defs><pattern id="hatch" width="7" height="7" patternTransform="rotate(45)" '
               'patternUnits="userSpaceOnUse"><rect width="7" height="7" fill="#b0413e"/>'
               '<line x1="0" y1="0" x2="0" y2="7" stroke="#e8e8e8" stroke-width="3"/></pattern></defs>')
    # title
    svg.append(f'<text x="{W/2}" y="34" font-size="20" font-weight="bold" text-anchor="middle">'
               'LA Major Construction Value by Year (2013–2023)</text>')
    svg.append(f'<text x="{W/2}" y="54" font-size="12" fill="#666" text-anchor="middle">'
               'New construction + additions over $1M — source: LA Open Data (LADBS)</text>')

    # y gridlines + labels ($B)
    step = 1e9
    g = step
    while g <= top + 1:
        gy = y(g)
        svg.append(f'<line x1="{ML}" y1="{gy:.1f}" x2="{W-MR}" y2="{gy:.1f}" stroke="#eee"/>')
        svg.append(f'<text x="{ML-10}" y="{gy+4:.1f}" font-size="11" fill="#666" text-anchor="end">'
                   f'${int(g/1e9)}B</text>')
        g += step

    # bars + value labels + year labels
    for i, (yr, v) in enumerate(zip(years, vals)):
        bx, by = x(i), y(v)
        bh = MT + plot_h - by
        partial = yr == PARTIAL_YEAR
        fill = 'url(#hatch)' if partial else '#2c5f8a'
        svg.append(f'<rect x="{bx:.1f}" y="{by:.1f}" width="{bw:.1f}" height="{bh:.1f}" fill="{fill}"/>')
        svg.append(f'<text x="{bx+bw/2:.1f}" y="{by-6:.1f}" font-size="10" fill="#333" '
                   f'text-anchor="middle">${v/1e9:.1f}B</text>')
        lbl = yr + ("*" if partial else "")
        svg.append(f'<text x="{bx+bw/2:.1f}" y="{H-MB+18}" font-size="11" fill="#333" '
                   f'text-anchor="middle">{lbl}</text>')

    # axis line
    svg.append(f'<line x1="{ML}" y1="{MT+plot_h}" x2="{W-MR}" y2="{MT+plot_h}" stroke="#333"/>')
    # footnote for the partial year
    svg.append(f'<text x="{ML}" y="{H-18}" font-size="11" fill="#b0413e">'
               '* 2023 is partial (data ends 2023-05-17) — not a full-year decline.</text>')
    svg.append('</svg>')

    with open(args.out, "w", encoding="utf-8") as f:
        f.write("\n".join(svg))

    print(f"Chart written: {args.out}  (cost: $0, stdlib SVG)")
    print(f"  years {years[0]}–{years[-1]}, peak ${vmax/1e9:.2f}B")
    print(f"  rasterize for slides (macOS): qlmanage -t -s 1600 -o . {args.out}")


if __name__ == "__main__":
    main()