#!/usr/bin/env python3 """Generate the four embeddable charts for field study No. 05 (Charleston Tri-County, SC). Every plotted value is READ FROM charleston-commercial-record-2026-09.csv at run time. Nothing is typed in by hand: if a metric is not in the CSV, this script raises rather than draw it. The CSV is produced by count_charleston.py, published beside it. python3 make-charts.py [path/to/charleston-commercial-record-2026-09.csv] Same drawing code as the Harris and Mecklenburg studies, so the five studies share one visual language.""" import csv, os, sys HERE = os.path.dirname(os.path.abspath(__file__)) CSV_PATH = sys.argv[1] if len(sys.argv) > 1 else os.path.join( HERE, "charleston-commercial-record-2026-09.csv") def load(path): vals = {} with open(path, newline="", encoding="utf-8") as fh: rows = [ln for ln in fh if not ln.startswith("#")] for r in csv.DictReader(rows): vals[r["metric"]] = (r["value"], r["share"]) if not vals: raise SystemExit("no metrics parsed from " + path) return vals V = load(CSV_PATH) def num(metric): """A figure, straight from the CSV. KeyError if it was never counted.""" if metric not in V: raise KeyError("not in the CSV, so it was not counted: " + metric) s = V[metric][0] return float(s) if "." in s else int(s) SURFACE="#f7f5ed"; INK="#173e32"; MUTED="#7b9384" ACCENT="#0d6b47"; GRAY="#b6c4b5"; GRID="#d9e0d4" FONT="system-ui,-apple-system,'Segoe UI',Helvetica,Arial,sans-serif" CREDIT="Sterling Digital Partners ยท glvtl.com/field-study" def bar(x,y,w,h,r=4): if w<=r: return f'' return (f'') def wrap(t,n=112): out,cur=[],"" for w in t.split(): if len(cur)+len(w)+1>n: out.append(cur); cur=w else: cur=(cur+" "+w).strip() if cur: out.append(cur) return out def chart(fn,title,sub,rows,axis_max,ticks,legend,note,W=720,L=248,fmt="{:,}",tickfmt=None): n=len(rows); band=38; top=92 tickfmt = tickfmt or fmt nl=wrap(note) H=top+n*band+34+len(legend)*19+len(nl)*16+32 R=66; pw=W-L-R s=[f'', f'{title}{sub} {note}', f'', f'{title}', f'{sub}'] for t in ticks: x=L+pw*t/axis_max s.append(f'') s.append(f'{tickfmt.format(t)}') for i,(lab,val,hot) in enumerate(rows): y=top+i*band; w=pw*val/axis_max; c=ACCENT if hot else GRAY s.append(f'{lab}') s.append(f'{bar(L,y,w,20)}') s.append(f'{fmt.format(val)}') ly=top+n*band+32 for j,(sw,txt) in enumerate(legend): yy=ly+j*19 s.append(f'') s.append(f'{txt}') ny=ly+len(legend)*19+10 for k,line in enumerate(nl): s.append(f'{line}') s.append(f'{CREDIT}') s.append('') open(os.path.join(HERE,fn),'w').write('\n'.join(s)) print("wrote",fn,f"({W}x{H})") def stacked(fn,title,sub,rows,legend,note,W=720,L=206): nl=wrap(note); band=54; top=98 H=top+len(rows)*band+18+len(legend)*19+len(nl)*16+32 R=26; pw=W-L-R s=[f'', f'{title}{sub} {note}', f'', f'{title}', f'{sub}'] for i,(lab,a,b,ntxt) in enumerate(rows): y=top+i*band; tot=a+b wa=pw*a/tot; wb=pw*b/tot-2 s.append(f'{lab}') s.append(f'{ntxt}') s.append(f'{bar(L,y,wa,24,0)}') s.append(f'{bar(L+wa+2,y,wb,24)}') s.append(f'' f'{a*100.0/tot:.1f}%') s.append(f'' f'{b*100.0/tot:.1f}%') ly=top+len(rows)*band+6 for j,(sw,txt) in enumerate(legend): yy=ly+j*19 s.append(f'') s.append(f'{txt}') ny=ly+len(legend)*19+10 for k,line in enumerate(nl): s.append(f'{line}') s.append(f'{CREDIT}') s.append('') open(os.path.join(HERE,fn),'w').write('\n'.join(s)) print("wrote",fn,f"({W}x{H})") BUCKETS = ["Sold before 2012", "Sold 2012-2014", "Sold 2015-2017", "Sold 2018-2021", "Sold 2022-2025"] SHORT = {"Sold before 2012": "before 2012", "Sold 2012-2014": "2012-14", "Sold 2015-2017": "2015-17", "Sold 2018-2021": "2018-21", "Sold 2022-2025": "2022-25"} # --- Finding 01: the gradient ------------------------------------------------- rows = [] for county, label, hot in (("Berkeley commercial", "Berkeley", True), ("Dorchester 6% class", "Dorchester", False)): for b in BUCKETS: rows.append((f"{label}, sold {SHORT[b].replace('before ', 'before ')}", num(f"{county} / {b} / median gap below market"), hot)) chart("field-study-05-reset-gradient.svg", "What a commercial property is taxed on depends on when it last sold", "Median shortfall of taxable value below the county's own market value, " "by the year the property last changed hands", rows, 60, [0, 15, 30, 45, 60], [(ACCENT, "Berkeley County, parcels with a commercial building (n=3,357)"), (GRAY, "Dorchester County, the 6% assessment class (n=26,182)")], "South Carolina resets a property's taxable value when it changes hands. " "Two counties, two unrelated CAMA vendors, the same gradient. Counted " "20 September 2026 from each county's own public parcel service.", fmt="{:.1f}%", tickfmt="{}%") # --- Finding 01b: how many show any gap at all -------------------------------- rows = [] for county, label, hot in (("Berkeley commercial", "Berkeley", True), ("Dorchester 6% class", "Dorchester", False)): for b in BUCKETS: rows.append((f"{label}, sold {SHORT[b]}", num(f"{county} / {b} / share showing any gap"), hot)) chart("field-study-05-any-gap.svg", "The longer it has been held, the likelier it is taxed below market", "Share of commercial parcels whose taxable value sits more than 1% below " "market value", rows, 100, [0, 25, 50, 75, 100], [(ACCENT, "Berkeley County, parcels with a commercial building"), (GRAY, "Dorchester County, the 6% assessment class")], "Even among the most recently sold, some gap remains: a sale is not the " "only thing that can reset a value, and not every transfer is an " "assessable transfer of interest. Counted 20 September 2026.", fmt="{:.1f}%", tickfmt="{}%") # --- Finding 03: the column Charleston does not have -------------------------- chart("field-study-05-charleston-blank.svg", "The biggest county in the metro cannot show this at all", "Charleston County parcels carrying each value, of 197,620 in the " "county's public parcel layer", [("Appraised (market) value", num("Charleston parcels carrying an appraised (market) value"), False), ("Taxable or assessed value", num("Charleston parcels carrying a taxable or assessed value"), True)], 200000, [0, 50000, 100000, 150000, 200000], [(GRAY, "Published by Charleston County"), (ACCENT, "Not present in any of the layer's 47 columns")], "The gap between taxable and market value can only be measured where " "both are published. Charleston County publishes one of them. Counted " "20 September 2026.", fmt="{:,}", tickfmt="{:,}") # --- Finding 04: an undocumented code set ------------------------------------- codes = sorted(((k.split()[3], num(k)) for k in V if k.startswith("Berkeley Validity code ") and not k.startswith("Berkeley Validity code (blank)")), key=lambda kv: -kv[1])[:10] chart("field-study-05-validity.svg", "The metro's only sale-qualification field, and no published legend", "Rows carrying each Validity code on Berkeley County's 123,050-parcel " "roll, ten most common", [(f"Code {c}", n, c in ("0", "0A")) for c, n in codes], max(n for _, n in codes), [0, 15000, 30000, 45000, 60000], [(ACCENT, "Codes 0 and 0A, which behave like arm's-length in the data"), (GRAY, "Codes whose meaning the county does not publish")], "Every field in the service returns a null domain, the subtype list is " "empty, and no county or vendor document defines these codes. We count " "them; we do not name them. Counted 20 September 2026.", fmt="{:,}", tickfmt="{:,}")