[object Object]

← 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

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

← 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 →