108 lines
4.0 KiB
Python
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())
|