Files
200feet/scripts/build_web.py
T

108 lines
4.0 KiB
Python

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