"""Build the static-site data payload for web/. Reads cache/addresses.parquet, slims to {addr, lat, lng}, writes web/data/ addresses.json. Copies the GeoJSONs produced by analyze.py into web/data/. Generates web/data/manifest.json listing the available layers and scenarios. """ from __future__ import annotations import json import shutil import sys import time from pathlib import Path import geopandas as gpd ROOT = Path(__file__).resolve().parent.parent CACHE = ROOT / "cache" OUT = ROOT / "out" WEB_DATA = ROOT / "web" / "data" WEB_DATA.mkdir(parents=True, exist_ok=True) def build_addresses() -> None: t0 = time.time() print("loading addresses...", flush=True) addr = gpd.read_parquet(CACHE / "addresses.parquet").to_crs("EPSG:4326") addr = addr[addr.geometry.notna() & ~addr.geometry.is_empty].copy() addr["lng"] = addr.geometry.x.round(6) addr["lat"] = addr.geometry.y.round(6) addr["a"] = addr["FULLADDR"].fillna("").astype(str).str.strip() addr = addr[addr["a"] != ""].drop_duplicates(subset=["a", "lat", "lng"]) # Slim shape: list of [addr, lat, lng] triples. Position-only for compact JSON. rows = addr[["a", "lat", "lng"]].values.tolist() out_path = WEB_DATA / "addresses.json" with open(out_path, "w") as f: json.dump(rows, f, separators=(",", ":")) print(f" wrote {out_path.relative_to(ROOT)} " f"({len(rows):,} addresses, {out_path.stat().st_size/1e6:.2f} MB, " f"{time.time()-t0:.1f}s)") def copy_geojsons() -> dict: """Copy out/*.geojson into web/data/. Return a manifest dict. First sweep stale eligible_*.geojson and marginal_/homes_protected_ files from web/data/ that no longer exist in out/ -- otherwise a layer that used to be non-empty can keep serving its old polygons after a filter change made it empty. """ sources = {p.name for p in OUT.glob("*.geojson")} stale_prefixes = ("eligible_", "marginal_parcels_", "homes_protected_") for existing in WEB_DATA.glob("*.geojson"): if existing.name.startswith(stale_prefixes) and existing.name not in sources: existing.unlink() print(f" removed stale {existing.name}") layers = [] scenarios = {} for gj in sorted(OUT.glob("*.geojson")): dst = WEB_DATA / gj.name shutil.copyfile(gj, dst) size = dst.stat().st_size layers.append({"file": gj.name, "size_kb": round(size / 1024, 1)}) # Detect scenario suffix: eligible_200ft_buildable_scenarioFOO.geojson stem = gj.stem if "_" in stem: parts = stem.split("_") if len(parts) >= 4 and parts[0] == "eligible": ft = parts[1] # e.g. "200ft" tier = parts[2] # strict / buildable / siteable suffix = "_".join(parts[3:]) if len(parts) > 3 else "default" scenarios.setdefault(suffix, {}).setdefault(ft, {})[tier] = gj.name return {"layers": layers, "scenarios": scenarios} def write_manifest(manifest: dict) -> None: # Headline numbers parsed from CSVs so the page can show stats without # the user having to open files. headlines = {} for ft in ("200ft", "500ft", "1000ft"): for tier in ("strict", "buildable"): f = OUT / f"eligible_{ft}_{tier}.csv" if f.exists(): # rows = total lines - header n = max(0, sum(1 for _ in open(f)) - 1) headlines[f"{ft}_{tier}"] = n manifest["headlines"] = headlines manifest["generated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") out = WEB_DATA / "manifest.json" with open(out, "w") as f: json.dump(manifest, f, indent=2) print(f" wrote {out.relative_to(ROOT)}") def main() -> int: build_addresses() print("copying geojsons...") manifest = copy_geojsons() for layer in manifest["layers"]: print(f" {layer['file']:48s} {layer['size_kb']:>8.1f} KB") write_manifest(manifest) return 0 if __name__ == "__main__": sys.exit(main())