← back to La Permits School
Add make_chart.py: stdlib SVG 'value by year' bar chart (2023 hatched as partial); gitignore generated images
7bbff9c6649ce65ef4ebaccc84b53799398a28b4 · 2026-08-10 13:17:21 -0700 · Steve Abrams
Files touched
M .gitignoreM README.mdA make_chart.py
Diff
commit 7bbff9c6649ce65ef4ebaccc84b53799398a28b4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 13:17:21 2026 -0700
Add make_chart.py: stdlib SVG 'value by year' bar chart (2023 hatched as partial); gitignore generated images
---
.gitignore | 2 +
README.md | 11 +++++-
make_chart.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 131 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index b3c69ee..672f65f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ __pycache__/
.DS_Store
.env*
*.log
+value_by_year.svg
+value_by_year.png
diff --git a/README.md b/README.md
index 8fa0dbb..a741f96 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,16 @@ python3 pull_permits.py --limit 500 --out sample.csv
```bash
python3 summary.py --top 8
```
-4. **CA SOS + CSLB** (free lookups) → open the URLs on the firms you care about to
+4. **`make_chart.py`** → a slide-ready "value by year" bar chart (pure-stdlib SVG,
+ 2023 hatched as partial). Rasterize to PNG with a local renderer — no pip:
+ ```bash
+ python3 make_chart.py # -> value_by_year.svg
+ # then PNG (either works, both free/local):
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
+ --screenshot="$PWD/value_by_year.png" --window-size=960,540 "file://$PWD/value_by_year.svg"
+ # or: qlmanage -t -s 1600 -o . value_by_year.svg (square crop; Chrome keeps 16:9)
+ ```
+5. **CA SOS + CSLB** (free lookups) → open the URLs on the firms you care about to
resolve the legal entity + agent (SOS) and license status/classification (CSLB).
### Why the enrichment is a worklist, not an auto-scrape
diff --git a/make_chart.py b/make_chart.py
new file mode 100644
index 0000000..5186c70
--- /dev/null
+++ b/make_chart.py
@@ -0,0 +1,119 @@
+#!/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()
← d130dd7 Add FINDINGS.md: written analysis (2020 vs 2023 dip, CD-11/L
·
back to La Permits School
·
Add pull_permits_live.py: LIVE current LA permits (feed pi9x 4485919 →