← 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("&", "&").replace("<", "<").replace(">", ">")
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()