Initial commit: 200feet pipeline, docker packaging, and static site

This commit is contained in:
2026-07-02 00:38:32 -04:00
commit df18210492
31 changed files with 7188 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Keep the build context tiny — only need web/ and docker/ to build the image.
gis/
cache/
out/
.venv/
scripts/
.git/
.gitignore
__pycache__/
*.pyc
.DS_Store
Downloads/
*.parquet
*.geojson
!web/data/*.geojson
*.csv
!web/data/*.csv
*.pdf
.claude/
.idea/
.vscode/
+20
View File
@@ -0,0 +1,20 @@
# Python
.venv/
__pycache__/
*.pyc
*.pyo
# Raw source data (too large; rehydrate from upstream)
gis/
# Intermediate pipeline outputs
cache/
out/
# OS / editor
.DS_Store
.idea/
.vscode/
# Assistant / local tooling
.claude/
+29
View File
@@ -0,0 +1,29 @@
# Static site for 200feet.net. nginx serves web/ with gzip + sane cache headers.
# Build the contents of web/ first via:
# .venv/bin/python scripts/analyze.py
# .venv/bin/python scripts/compare_setbacks.py
# .venv/bin/python scripts/build_web.py
# Then:
# docker build -t registry.davidadams.rocks/200feet:latest .
# docker push registry.davidadams.rocks/200feet:latest
FROM nginx:1.27-alpine
# Update packages to patch vulnerabilities
RUN apk update && apk upgrade && rm -rf /var/cache/apk/*
# Drop nginx's default site
RUN rm -f /etc/nginx/conf.d/default.conf
COPY docker/nginx.conf /etc/nginx/conf.d/200feet.conf
COPY web/ /usr/share/nginx/html/
# nginx runs as 'nginx' user (uid 101) by default in the alpine image.
# /var/cache/nginx and /var/run are writable for that user already.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]
+33
View File
@@ -0,0 +1,33 @@
# Portainer stack for 200feet.net.
# In Portainer: Stacks -> Add stack -> Repository (this file) OR paste the YAML.
#
# Replace the published-port mapping with your reverse-proxy labels if you run
# Traefik/Caddy in front (see commented-out labels below).
services:
web:
image: registry.davidadams.rocks/200feet:latest
container_name: 200feet-web
restart: unless-stopped
pull_policy: always
ports:
- "8080:80" # remove if your reverse proxy attaches by label
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
# ---- Optional Traefik labels (uncomment + edit if you use Traefik) ----
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.200feet.rule=Host(`200feet.info`)"
# - "traefik.http.routers.200feet.entrypoints=websecure"
# - "traefik.http.routers.200feet.tls.certresolver=letsencrypt"
# - "traefik.http.services.200feet.loadbalancer.server.port=80"
networks:
- default
networks:
default:
name: 200feet
+78
View File
@@ -0,0 +1,78 @@
# nginx site config for 200feet — static assets, aggressive compression,
# long cache on hashed/static GeoJSON, short cache on HTML so updates ship fast.
map $sent_http_content_type $expires {
default off;
text/html 10m;
application/json 1h;
application/geo+json 1h;
text/css 7d;
application/javascript 7d;
image/svg+xml 7d;
image/png 30d;
image/jpeg 30d;
font/woff2 30d;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Strong compression -- geojson compresses ~3x.
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 256;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/geo+json
application/xml
image/svg+xml
font/woff2;
# NOTE: do NOT redefine types {} here -- a types block in a server scope
# REPLACES the inherited /etc/nginx/mime.types instead of extending it,
# which makes nginx return application/octet-stream for everything else.
# .geojson files end up served as application/json (which fetch().json()
# handles fine), since the layer's location adds an explicit type below.
expires $expires;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Modest CSP. Leaflet + Turf + CARTO tiles + Google fonts are the third parties.
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https://*.basemaps.cartocdn.com; script-src 'self' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
# Healthcheck for Portainer / Docker
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
# SPA-style fallback in case we ever add client routing.
location / {
try_files $uri $uri/ /index.html;
}
# Data files: longer cache acceptable, content keyed off the build.
location /data/ {
add_header Cache-Control "public, max-age=3600";
# Ensure JSON-ish content types for .geojson / .json
location ~* \.geojson$ { default_type application/geo+json; }
location ~* \.json$ { default_type application/json; }
try_files $uri =404;
}
# Don't log favicon / robot probes.
location = /favicon.ico { access_log off; log_not_found off; return 204; }
location = /robots.txt { access_log off; log_not_found off; return 200 "User-agent: *\nAllow: /\n"; }
}
+683
View File
@@ -0,0 +1,683 @@
"""Johnson City Data Center / Bitcoin mine eligibility analysis.
NOTE: based on DRAFT ordinance text under consideration (Section 6.20.3.2),
not enacted law. Treat results as informational only.
Setback ordinance (verbatim, 6.20.3.2.I):
"No Data Center facility shall be located within 200 feet of any residential
use or district. This includes any zoning district that permits single family
residences or dwellings, as well as RM-3, RM-4, and RM-5 districts.
The measurement shall be made from the nearest property line or zoning line
of the residential use or district, whichever is closer, to the nearest
property line of the Data Center."
Also from 6.20.3.2.I (DC-specific yard setbacks inside the parcel):
"The minimum front yard setback shall be sixty (60) feet. The minimum side
and rear setbacks shall be fifty (50) feet."
These OVERRIDE the I-2 default yard setbacks for a Data Center facility.
Data Centers in I-2 are a SPECIAL EXCEPTION (BZA approval required) -- this
analysis filters on geometric eligibility only; passing the geometric gate is
necessary but not sufficient for a permit.
Interpretation:
* Data Center is permitted only on I-2 (Heavy Industrial), as special exception.
* Source of measurement = polygon boundary of EITHER a residential-permitting
zoning district OR a parcel with a residential use; whichever is closer.
* Target of measurement = property line of the Data Center parcel.
* Buffering each source polygon by 200ft and testing parcel-boundary
intersection captures this exactly (line-to-line distance via buffer set).
Three eligibility views per parcel:
STRICT : whole parcel sits >= 200ft from any residential line.
(Treats the entire parcel as the "Data Center property line.")
BUILDABLE: largest contiguous patch inside parcel AND inside I-2 zoning AND
>= 200ft from any residential line. A developer could subdivide a
compliant sub-parcel from this footprint. The patch itself is
potentially developable land.
SITE-ABLE: BUILDABLE patch eroded inward by the DC's own 50ft side/rear
yard setback (6.20.3.2.I). What's left is the maximum BUILDING
footprint achievable on the parcel. This is the metric that
answers "how big a facility can you actually fit here."
Wargame: --exclude-gislinks removes those parcels from the residential
exclusion set (simulate a buyer demolishing the houses on those parcels).
Exploratory wider buffers (500/1000ft) are reported but labeled non-ordinance.
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
import geopandas as gpd
import pandas as pd
from shapely import make_valid, unary_union
from shapely.geometry import MultiPolygon, Polygon
from shapely.ops import polylabel
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
OUT = ROOT / "out"
OUT.mkdir(exist_ok=True)
# Default min largest-contiguous-buildable-patch (sqft). Tunable via --min-sqft.
# Reference: small container mine ~1-5k sqft; warehouse mine ~20k+ sqft.
DEFAULT_MIN_USABLE_SQFT = 5000
# DC building yard setback inside the parcel (6.20.3.2.I): 60ft front / 50ft
# side & rear. We erode the buildable polygon by 50ft on all sides (developer
# will orient the building so the side abutting the road takes the front
# setback; the difference between 60 and 50 is small relative to parcel size).
DC_INTERNAL_SETBACK_FT = 50
# Strict-eligible parcels under this much I-2 acreage are filtered out (mostly
# right-of-way slivers in the source data that pass the clearance test
# trivially because they're a few square feet in size). Tunable via CLI.
DEFAULT_STRICT_MIN_I2_ACRES = 0.10 # ~4,356 sqft
# Minimum building width (ft). The siteable patch must contain an inscribed
# circle of this diameter so a real building can fit, not just a thin strip.
# 50ft default = a 50x50 building footprint can fit somewhere in the patch.
DEFAULT_MIN_BUILDING_WIDTH_FT = 50
# JC Subdivision Regs §4-4.1: every subdivided lot must have at least 50 ft
# of frontage on a public right-of-way (40 ft on cul-de-sac). We approximate
# road frontage by finding the portion of a parcel's boundary that does NOT
# lie along another parcel's boundary (roads are the gaps between parcels).
DEFAULT_MIN_FRONTAGE_FT = 50
# Half-width of a typical road right-of-way. The road centerline file is a
# LineString through the middle of each road; a parcel that fronts the road
# has its boundary roughly ROW_HALF_WIDTH_FT from the centerline. We treat any
# parcel-boundary segment within this distance of a public road centerline as
# road frontage. Set generously (50ft) to catch wider feeder/collector ROWs
# that can be 80-100ft total (half-width 40-50ft).
ROW_HALF_WIDTH_FT = 50
# Road classes / drive types treated as non-public (excluded from frontage).
NONPUBLIC_ROAD_CLASSES = {"Private", "Retired"}
# Zoning districts that satisfy the ordinance trigger: "any zoning district
# that permits single family residences or dwellings, as well as RM-3, RM-4,
# and RM-5 districts."
#
# The "as well as RM-3/RM-4/RM-5" carve-in implies the preceding clause is
# narrow (SFR-permitting only) -- if "or dwellings" meant any dwelling, the
# RM-3/4/5 enumeration would be surplusage. So we include only districts that
# permit single-family residences, plus the explicit RM-3/4/5.
#
# MX and MX-1 EXCLUDE single-family detached dwellings by ordinance and are
# not named in the carve-in, so they are NOT residential districts here.
# (Any actual dwelling on MX/MX-1 land is still captured via the residential
# USE path -- LANDUSE codes and address-point joins.)
RESIDENTIAL_DISTRICT_ZONECLASSES = {
# Residential districts (SFR permitted by-right)
"R-1", "R-2", "R-2A", "R-2B", "R-2C", "R-3", "R-4", "R-5", "R-6",
# Planned Residential (SFR permitted)
"RP-2", "RP-3", "RP-4", "RP-5", "RP-6",
# Residential-Office hybrid (SFR permitted)
"RO-1", "RO-2",
# Agricultural (farm SFR permitted)
"A-1",
# Manufactured home parks (explicitly named in ordinance carve-in)
"RM-3", "RM-4", "RM-5",
}
# Parcel LANDUSE codes indicating an existing residential use.
# 11 Household Units; 12 Group Quarters; 13 Residential Hotels; 14 Mobile Home Parks.
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
# Parcel LANDUSE codes that disqualify a parcel from being a data-center site
# regardless of zoning -- linear infrastructure that physically can't host a
# building (active rail right-of-way is the main one). 41 = rail transportation.
NON_BUILDABLE_LANDUSE_CODES = {"41"}
# Owner-name patterns that indicate railroad-owned right-of-way even when
# LANDUSE is unset. Case-insensitive substring match on the OWNER field.
NON_BUILDABLE_OWNER_KEYWORDS = (
"RAILROAD", "RAILWAY", " RY ", " RY,", " RY.", " R R ", " RR ",
"CSX TRANSPORTATION", "NORFOLK SOUTHERN", " R/R",
)
# Address PLACETYPE values indicating a residence at that point.
RESIDENTIAL_PLACETYPES = {
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
}
# Address PLACETYPE values that explicitly mark a parcel as currently vacant.
# When the address dataset (which is kept current for 911/utility purposes)
# marks a parcel vacant but its LANDUSE code is stale "household units" from
# the last assessment, we trust the address. Avoids treating long-cleared
# industrial lots as residential exclusions.
VACANT_PLACETYPES = {"VACANT LOT", "VACANT", "VACANT UNIT"}
# Buffers reported. 200 is the ordinance threshold; 500 / 1000 are exploratory.
SETBACKS_FT = [200, 500, 1000]
ORDINANCE_SETBACK_FT = 200
def stamp(msg: str, start: float | None = None) -> float:
now = time.time()
if start is None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
else:
print(f"[{time.strftime('%H:%M:%S')}] {msg} ({now - start:.1f}s)", flush=True)
return now
def fix(geom):
if geom is None or geom.is_empty:
return geom
if not geom.is_valid:
return make_valid(geom)
return geom
def _inscribed_radius(geom, tolerance: float = 5.0) -> float:
"""Largest inscribed circle radius (ft) across all polygon parts of geom."""
if geom is None or geom.is_empty:
return 0.0
polys = []
if isinstance(geom, Polygon):
polys = [geom]
elif isinstance(geom, MultiPolygon):
polys = list(geom.geoms)
else:
try:
for g in geom.geoms:
if isinstance(g, Polygon):
polys.append(g)
elif isinstance(g, MultiPolygon):
polys.extend(g.geoms)
except AttributeError:
return 0.0
best = 0.0
for p in polys:
if p.is_empty or p.area < 1.0:
continue
try:
pt = polylabel(p, tolerance=tolerance)
r = p.exterior.distance(pt)
if r > best:
best = r
except Exception:
continue
return best
def _write_gjs(gdf, path: Path) -> None:
"""Reproject a GeoDataFrame to EPSG:4326 and write as GeoJSON."""
gdf = gdf.to_crs("EPSG:4326")
if path.exists():
path.unlink()
gdf.to_file(path, driver="GeoJSON")
print(f" wrote {path.relative_to(ROOT)} ({len(gdf):,} features, {path.stat().st_size/1024:.1f} KB)")
def _max_polygon_area(geom) -> float:
"""Return area (sqft) of the single largest contiguous polygon piece."""
if geom is None or geom.is_empty:
return 0.0
if isinstance(geom, Polygon):
return geom.area
if isinstance(geom, MultiPolygon):
return max((g.area for g in geom.geoms), default=0.0)
# GeometryCollection etc.
try:
polys = [g for g in geom.geoms if isinstance(g, (Polygon, MultiPolygon))]
return max((_max_polygon_area(p) for p in polys), default=0.0)
except AttributeError:
return 0.0
def landuse_is_residential(landuse: str | None) -> bool:
if landuse is None or pd.isna(landuse):
return False
prefix = str(landuse).split(" - ", 1)[0]
codes = {c.strip() for c in prefix.split(":") if c.strip()}
return bool(codes & RESIDENTIAL_LANDUSE_CODES)
def landuse_is_non_buildable(landuse: str | None) -> bool:
if landuse is None or pd.isna(landuse):
return False
prefix = str(landuse).split(" - ", 1)[0]
codes = {c.strip() for c in prefix.split(":") if c.strip()}
return bool(codes & NON_BUILDABLE_LANDUSE_CODES)
def owner_is_railroad(owner: str | None) -> bool:
if owner is None or pd.isna(owner):
return False
s = f" {str(owner).upper()} "
return any(kw in s for kw in NON_BUILDABLE_OWNER_KEYWORDS)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"--exclude-gislinks", nargs="*", default=[],
help="GISLINKs of parcels to treat as NON-residential (wargame mode).",
)
ap.add_argument(
"--drop-a1", action="store_true",
help="Sensitivity test: do NOT count A-1 (Agricultural) as a residential district.",
)
ap.add_argument(
"--min-sqft", type=float, default=DEFAULT_MIN_USABLE_SQFT,
help=f"Min largest-contiguous BUILDABLE patch (sqft) to report. "
f"Default {DEFAULT_MIN_USABLE_SQFT}.",
)
ap.add_argument(
"--min-building-sqft", type=float, default=None,
help="If set, also require SITE-ABLE area (buildable patch after DC's "
"own 50ft side/rear setback) >= this many sqft. Use this to filter "
"for parcels where an actual building of this size can fit.",
)
ap.add_argument(
"--out-suffix", type=str, default="",
help="Suffix appended to output filenames so sweeps don't clobber the "
"canonical CSVs. Example: --out-suffix _scenarioA -> "
"eligible_200ft_buildable_scenarioA.csv",
)
ap.add_argument(
"--no-geojson", action="store_true",
help="Skip GeoJSON outputs (write CSVs only). Default writes GeoJSON "
"for map rendering, reprojected back to EPSG:4326 (lon/lat).",
)
ap.add_argument(
"--min-width-ft", type=float, default=DEFAULT_MIN_BUILDING_WIDTH_FT,
help=f"Minimum width (ft) of an inscribed building footprint in the "
f"siteable patch. Default {DEFAULT_MIN_BUILDING_WIDTH_FT} ft. "
f"Set to 0 to disable shape filtering.",
)
ap.add_argument(
"--strict-min-i2-acres", type=float, default=DEFAULT_STRICT_MIN_I2_ACRES,
help=f"Strict-eligible parcels must have at least this much I-2 acreage "
f"(filters out ROW slivers). Default {DEFAULT_STRICT_MIN_I2_ACRES}.",
)
ap.add_argument(
"--min-frontage-ft", type=float, default=DEFAULT_MIN_FRONTAGE_FT,
help=f"Minimum public-road frontage (ft) per JC Subdivision Reg §4-4.1. "
f"Default {DEFAULT_MIN_FRONTAGE_FT}. Set to 0 to disable.",
)
args = ap.parse_args()
exclude_gislinks = set(args.exclude_gislinks)
min_sqft = args.min_sqft
min_building_sqft = args.min_building_sqft
out_suffix = args.out_suffix
write_geojson = not args.no_geojson
min_width_ft = args.min_width_ft
strict_min_i2_acres = args.strict_min_i2_acres
min_radius_ft = min_width_ft / 2.0
min_frontage_ft = args.min_frontage_ft
res_district_classes = set(RESIDENTIAL_DISTRICT_ZONECLASSES)
if args.drop_a1:
res_district_classes -= {"A-1"}
# Wipe stale per-setback outputs with the matching suffix so missing tiers
# (e.g., strict=0 at 500ft) don't leave behind files from previous runs.
for ft in SETBACKS_FT:
for tier in ("strict", "buildable", "siteable"):
for ext in (".csv", ".geojson"):
p = OUT / f"eligible_{ft}ft_{tier}{out_suffix}{ext}"
if p.exists():
p.unlink()
t0 = stamp("loading cached layers")
zoning = gpd.read_parquet(CACHE / "zoning.parquet")
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
roads = gpd.read_parquet(CACHE / "roads.parquet")
stamp(f"loaded zoning={len(zoning):,} parcels={len(parcels):,} "
f"addresses={len(addresses):,} roads={len(roads):,}", t0)
assert zoning.crs == parcels.crs == addresses.crs == roads.crs
# Public roads only -- private drives and retired segments don't count
# toward subdivision-eligible road frontage.
public_road_mask = (
~roads["ROADCLASS_PW"].astype(str).isin(NONPUBLIC_ROAD_CLASSES)
& roads["PRIVDRIVETYPE"].isna()
)
public_roads = roads[public_road_mask].copy()
print(f" public roads: {len(public_roads):,} segments of {len(roads):,}")
t1 = stamp("fixing invalid geometries")
zoning["geometry"] = zoning.geometry.apply(fix)
parcels["geometry"] = parcels.geometry.apply(fix)
parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
stamp("done", t1)
# ---- I-2 footprint ----
t1 = stamp("building I-2 footprint")
i2_zones = zoning[zoning["ZONECLASS"] == "I-2"].copy()
i2_union = unary_union(i2_zones.geometry.values)
print(f" I-2 zones: {len(i2_zones)}; total acres: {i2_union.area/43560:.1f}")
stamp("done", t1)
# ---- Candidate parcels: any parcel intersecting any I-2 zone ----
t1 = stamp("identifying parcels intersecting I-2")
parcels_sindex = parcels.sindex
cand_idx = sorted(parcels_sindex.query(i2_union, predicate="intersects"))
candidates = parcels.iloc[cand_idx].copy()
candidates["i2_geom"] = candidates.geometry.intersection(i2_union).apply(fix)
candidates = candidates[~candidates["i2_geom"].is_empty].copy()
candidates["parcel_sqft"] = candidates.geometry.area
candidates["i2_sqft"] = candidates["i2_geom"].area
candidates["parcel_acres"] = candidates["parcel_sqft"] / 43560.0
candidates["i2_acres"] = candidates["i2_sqft"] / 43560.0
n_intersect = len(candidates)
print(f" parcels intersecting I-2: {n_intersect:,}")
# Drop parcels that are active rail right-of-way or other linear
# infrastructure that can't host a building (regardless of geometric
# eligibility). Identified by LANDUSE code 41 or railroad-style owner.
nb_lu_mask = candidates["LANDUSE"].apply(landuse_is_non_buildable)
nb_owner_mask = candidates["OWNER"].apply(owner_is_railroad)
nb_mask = nb_lu_mask | nb_owner_mask
if nb_mask.any():
dropped = candidates[nb_mask][["GISLINK", "ADDRESS", "OWNER", "LANDUSE"]]
print(f" dropping {nb_mask.sum()} non-buildable infrastructure parcel(s):")
for _, r in dropped.iterrows():
print(f" {r['GISLINK']!s:<22} {r['ADDRESS']!s:<30} {r['OWNER']!s:<32} {r['LANDUSE']!s}")
candidates = candidates[~nb_mask].copy()
print(f" buildable-eligible candidates: {len(candidates):,}")
stamp("done", t1)
# ---- Road frontage per parent parcel (authoritative centerlines) ----
# For each parcel, frontage = length of parcel boundary within
# ROW_HALF_WIDTH_FT of any PUBLIC road centerline. The centerline file is
# a LineString through the middle of each road; a parcel boundary roughly
# at the ROW edge sits ~25-35 ft from the centerline.
t1 = stamp("computing road frontage from centerlines")
roads_sindex = public_roads.sindex
road_geoms = public_roads.geometry.values
road_frontage_geoms = []
parent_frontage_ft_list = []
for row in candidates.itertuples(index=False):
pg = row.geometry
nearby_ids = list(roads_sindex.query(pg.buffer(ROW_HALF_WIDTH_FT + 5)))
if not nearby_ids:
road_frontage_geoms.append(None)
parent_frontage_ft_list.append(0.0)
continue
corridor = unary_union([road_geoms[i] for i in nearby_ids]).buffer(ROW_HALF_WIDTH_FT)
front = pg.boundary.intersection(corridor)
if front.is_empty:
road_frontage_geoms.append(None)
parent_frontage_ft_list.append(0.0)
else:
road_frontage_geoms.append(front)
parent_frontage_ft_list.append(round(front.length, 1))
candidates["road_frontage_geom"] = road_frontage_geoms
candidates["parent_frontage_ft"] = parent_frontage_ft_list
n_with = (candidates["parent_frontage_ft"] >= min_frontage_ft).sum()
print(f" parents with >= {int(min_frontage_ft)} ft public road frontage: "
f"{n_with}/{len(candidates)}")
stamp("done", t1)
inv_cols = ["GISLINK", "ADDRESS", "OWNER", "OWNER2", "ZONECLASS",
"LANDUSE", "PROPTYPE", "parcel_acres", "i2_acres"]
candidates[[c for c in inv_cols if c in candidates.columns]] \
.sort_values("i2_acres", ascending=False) \
.to_csv(OUT / "i2_parcels.csv", index=False)
print(f" wrote out/i2_parcels.csv")
if write_geojson:
# Static layers (independent of setback): I-2 zoning polygons and the
# full candidate-parcel set. Written once per run; suffix omitted so
# the map can always rely on these names.
_write_gjs(i2_zones[["ZONECLASS", "ZONEDESC", "geometry"]].copy(),
OUT / "i2_zones.geojson")
parcels_layer = candidates[[c for c in inv_cols if c in candidates.columns] + ["geometry"]].copy()
_write_gjs(parcels_layer, OUT / "i2_parcels.geojson")
# ---- Residential exclusion universe ----
t1 = stamp("building residential exclusion set")
zc = zoning["ZONECLASS"].fillna("").astype(str)
res_zone_mask = zc.isin(res_district_classes)
res_zones = zoning[res_zone_mask].copy()
print(f" residential district polygons : {len(res_zones):,} "
f"({sorted(res_zones['ZONECLASS'].unique())})")
# Address points: classify into residential / vacant for ground-truth
# signals that override stale LANDUSE codes.
addr_pt = addresses["PLACETYPE"].astype(str).str.upper()
res_addrs = addresses[addr_pt.isin(RESIDENTIAL_PLACETYPES)].copy()
vacant_addrs = addresses[addr_pt.isin(VACANT_PLACETYPES)].copy()
res_addr_in_parcels = gpd.sjoin(
res_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
how="inner", predicate="within",
)
res_addr_gislinks = set(res_addr_in_parcels["GISLINK"].dropna().unique())
vac_addr_in_parcels = gpd.sjoin(
vacant_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
how="inner", predicate="within",
)
vac_addr_gislinks = set(vac_addr_in_parcels["GISLINK"].dropna().unique())
# Override set: parcels whose address record explicitly says VACANT and has
# no residential address point. These trump a stale residential LANDUSE.
landuse_override_gislinks = vac_addr_gislinks - res_addr_gislinks
# (B) Residential LANDUSE -- but exclude parcels overridden by addresses.
res_landuse_mask = parcels["LANDUSE"].apply(landuse_is_residential) \
& ~parcels["GISLINK"].isin(landuse_override_gislinks)
res_landuse_parcels = parcels[res_landuse_mask].copy()
n_overridden = (parcels["LANDUSE"].apply(landuse_is_residential)
& parcels["GISLINK"].isin(landuse_override_gislinks)).sum()
print(f" parcels w/ residential LANDUSE : {len(res_landuse_parcels):,} "
f"(dropped {n_overridden} as VACANT per address record)")
# (C) Parcels containing a residential address point.
res_addr_parcels = parcels[parcels["GISLINK"].isin(res_addr_gislinks)].copy()
print(f" parcels containing a dwelling : {len(res_addr_parcels):,}")
if exclude_gislinks:
before_l = len(res_landuse_parcels); before_a = len(res_addr_parcels)
res_landuse_parcels = res_landuse_parcels[~res_landuse_parcels["GISLINK"].isin(exclude_gislinks)]
res_addr_parcels = res_addr_parcels[~res_addr_parcels["GISLINK"].isin(exclude_gislinks)]
print(f" wargame excluded {len(exclude_gislinks)} GISLINKs "
f"(LANDUSE {before_l}->{len(res_landuse_parcels)}, "
f"addr {before_a}->{len(res_addr_parcels)})")
res_geoms = (
list(res_zones.geometry.values)
+ list(res_landuse_parcels.geometry.values)
+ list(res_addr_parcels.geometry.values)
)
print(f" total residential geoms (pre-union): {len(res_geoms):,}")
stamp("collected", t1)
t1 = stamp("union of residential geoms")
residential_union = unary_union(res_geoms)
stamp("done", t1)
with open(OUT / "residential_summary.txt", "w") as f:
f.write(f"Residential districts in scope : {sorted(res_district_classes)}\n")
f.write(f"Residential district polygons : {len(res_zones):,}\n")
f.write(f"Parcels w/ residential LANDUSE : {len(res_landuse_parcels):,}\n")
f.write(f"Parcels w/ a dwelling address : {len(res_addr_parcels):,}\n")
f.write(f"Wargame-excluded GISLINKs : {len(exclude_gislinks)}\n")
f.write(f"Residential union area (acres) : {residential_union.area/43560:.1f}\n")
# ---- For each setback compute STRICT and BUILDABLE eligibility ----
for ft in SETBACKS_FT:
t1 = stamp(f"buffering residential by {ft} ft")
excl = residential_union.buffer(ft)
excl_sindex = gpd.GeoSeries([excl], crs=parcels.crs).sindex
stamp("done", t1)
t1 = stamp(f"computing eligibility @ {ft} ft")
strict_flags = []
total_sqft = []
max_contig_sqft = []
max_siteable_sqft = []
inscribed_diam = []
patch_frontage_ft = []
buildable_geoms = []
siteable_geoms = []
for row in candidates.itertuples(index=False):
parcel_g = row.geometry
i2g = row.i2_geom
road_g = row.road_frontage_geom
# Strict: entire parcel boundary >= ft from residential.
hits = list(excl_sindex.query(parcel_g, predicate="intersects"))
if not hits:
strict_flags.append(True)
total_sqft.append(i2g.area)
max_contig_sqft.append(_max_polygon_area(i2g))
buildable_geoms.append(i2g)
eroded = fix(i2g.buffer(-DC_INTERNAL_SETBACK_FT))
max_siteable_sqft.append(_max_polygon_area(eroded))
siteable_geoms.append(eroded)
inscribed_diam.append(2 * _inscribed_radius(eroded))
# For strict (whole-parcel) the relevant frontage is the parent's.
patch_frontage_ft.append(row.parent_frontage_ft)
continue
strict_flags.append(False)
# Buildable remnant inside parcel AND inside I-2 AND >= ft from residential.
remnant = fix(i2g.difference(excl))
if remnant.is_empty:
total_sqft.append(0.0)
max_contig_sqft.append(0.0)
max_siteable_sqft.append(0.0)
buildable_geoms.append(remnant)
siteable_geoms.append(remnant)
inscribed_diam.append(0.0)
patch_frontage_ft.append(0.0)
else:
total_sqft.append(remnant.area)
max_contig_sqft.append(_max_polygon_area(remnant))
buildable_geoms.append(remnant)
eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT))
max_siteable_sqft.append(_max_polygon_area(eroded))
siteable_geoms.append(eroded)
inscribed_diam.append(2 * _inscribed_radius(eroded))
# Patch frontage = length of patch boundary lying along parent
# road frontage. Buffer the road geom slightly to handle small
# numerical mismatches from the difference operation.
if road_g is None or road_g.is_empty:
patch_frontage_ft.append(0.0)
else:
# 5ft snap tolerance for numerical alignment between
# parent boundary and patch boundary.
front = remnant.boundary.intersection(road_g.buffer(5))
patch_frontage_ft.append(round(front.length, 1) if not front.is_empty else 0.0)
stamp("done", t1)
candidates[f"strict_{ft}"] = strict_flags
candidates[f"buildable_sqft_{ft}"] = total_sqft
candidates[f"max_contig_sqft_{ft}"] = max_contig_sqft
candidates[f"max_siteable_sqft_{ft}"] = max_siteable_sqft
candidates[f"inscribed_diam_ft_{ft}"] = [round(d, 1) for d in inscribed_diam]
candidates[f"patch_frontage_ft_{ft}"] = patch_frontage_ft
candidates[f"buildable_acres_{ft}"] = [a / 43560.0 for a in total_sqft]
candidates[f"max_contig_acres_{ft}"] = [a / 43560.0 for a in max_contig_sqft]
candidates[f"max_siteable_acres_{ft}"] = [a / 43560.0 for a in max_siteable_sqft]
candidates[f"buildable_pct_of_i2_{ft}"] = (
candidates[f"buildable_sqft_{ft}"] / candidates["i2_sqft"] * 100
).round(1)
candidates[f"_buildable_geom_{ft}"] = buildable_geoms
candidates[f"_siteable_geom_{ft}"] = siteable_geoms
# Strict eligibility output. Filters: parcel passes clearance, parcel
# has meaningful I-2 acreage (no ROW slivers), siteable patch has
# inscribed-circle diameter >= min_width_ft, AND the PARENT parcel
# has >= min_frontage_ft of public road frontage so the parcel could
# legally subdivide if needed (JC Subdivision Reg §4-4.1).
strict_mask = candidates[f"strict_{ft}"] \
& (candidates["i2_acres"] >= strict_min_i2_acres) \
& (candidates[f"inscribed_diam_ft_{ft}"] >= min_width_ft) \
& (candidates["parent_frontage_ft"] >= min_frontage_ft)
strict = candidates[strict_mask].copy()
s_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
"parcel_acres", "i2_acres",
f"max_siteable_acres_{ft}", f"inscribed_diam_ft_{ft}",
"parent_frontage_ft", f"patch_frontage_ft_{ft}"]
out_strict = OUT / f"eligible_{ft}ft_strict{out_suffix}.csv"
strict[[c for c in s_cols if c in strict.columns]] \
.sort_values("i2_acres", ascending=False) \
.to_csv(out_strict, index=False)
# Buildable filter: largest contiguous patch >= min_sqft, inscribed
# building circle diameter >= min_width_ft (so a real building fits),
# PARENT parcel has >= min_frontage_ft of public road frontage. We
# filter on PARENT frontage (not patch frontage) because the 50ft
# subdivision rule applies to the sub-parcel boundary the developer
# draws -- and the developer can draw lot lines to include road-
# adjacent land outside the compliant patch, as long as the data
# center building itself sits inside the patch.
build_mask = (candidates[f"max_contig_sqft_{ft}"] >= min_sqft) \
& (candidates[f"inscribed_diam_ft_{ft}"] >= min_width_ft) \
& (candidates["parent_frontage_ft"] >= min_frontage_ft)
if min_building_sqft is not None:
build_mask &= candidates[f"max_siteable_sqft_{ft}"] >= min_building_sqft
buildable = candidates[build_mask].copy()
b_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
"parcel_acres", "i2_acres",
f"max_contig_acres_{ft}", f"max_siteable_acres_{ft}",
f"inscribed_diam_ft_{ft}",
"parent_frontage_ft", f"patch_frontage_ft_{ft}",
f"buildable_acres_{ft}", f"buildable_pct_of_i2_{ft}"]
out_build = OUT / f"eligible_{ft}ft_buildable{out_suffix}.csv"
buildable[[c for c in b_cols if c in buildable.columns]] \
.sort_values(f"max_siteable_acres_{ft}", ascending=False) \
.to_csv(out_build, index=False)
flag = "ORDINANCE" if ft == ORDINANCE_SETBACK_FT else "exploratory"
extra = f", siteable >= {int(min_building_sqft):,}" if min_building_sqft else ""
print(f" setback {ft}ft ({flag}): "
f"strict={len(strict):,} "
f"buildable(contig >= {int(min_sqft):,} sqft, "
f"width >= {int(min_width_ft)}ft, frontage >= {int(min_frontage_ft)}ft{extra})={len(buildable):,}")
if write_geojson:
attr_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
"parcel_acres", "i2_acres",
f"max_contig_acres_{ft}", f"max_siteable_acres_{ft}",
f"inscribed_diam_ft_{ft}",
"parent_frontage_ft", f"patch_frontage_ft_{ft}",
f"buildable_acres_{ft}", f"buildable_pct_of_i2_{ft}"]
attr_cols = [c for c in attr_cols if c in candidates.columns]
# Strict-eligible parcels (whole parcel polygons)
if len(strict):
strict_layer = gpd.GeoDataFrame(
strict[attr_cols], geometry=strict["geometry"], crs=candidates.crs,
)
_write_gjs(strict_layer, OUT / f"eligible_{ft}ft_strict{out_suffix}.geojson")
# Buildable patches: the actual sub-parcel polygons. One feature per
# eligible parcel; geometry is the patch (post-buffer-subtraction).
if len(buildable):
build_layer = gpd.GeoDataFrame(
buildable[attr_cols],
geometry=buildable[f"_buildable_geom_{ft}"].values,
crs=candidates.crs,
)
build_layer = build_layer[~build_layer.geometry.is_empty]
_write_gjs(build_layer, OUT / f"eligible_{ft}ft_buildable{out_suffix}.geojson")
# Site-able patches (buildable eroded by DC's 50ft internal yard)
site_layer = gpd.GeoDataFrame(
buildable[attr_cols],
geometry=buildable[f"_siteable_geom_{ft}"].values,
crs=candidates.crs,
)
site_layer = site_layer[~site_layer.geometry.is_empty]
_write_gjs(site_layer, OUT / f"eligible_{ft}ft_siteable{out_suffix}.geojson")
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Build the static payload, cross-build the Docker image for linux/amd64
# (the typical Portainer host arch), push to the registry.
# Usage: scripts/build_and_push.sh [tag]
# Default tag = current date + short git sha (if git repo) else 'latest'.
#
# Env vars:
# PLATFORMS - default "linux/amd64". Set to "linux/amd64,linux/arm64" for
# multi-arch (registry must support OCI manifest lists; most do).
# PY - default ".venv/bin/python"
# SKIP_GEN - "1" to skip regeneration of analysis outputs.
set -euo pipefail
cd "$(dirname "$0")/.."
REGISTRY="registry.davidadams.rocks"
IMAGE="${REGISTRY}/200feet"
PLATFORMS="${PLATFORMS:-linux/amd64}"
PY="${PY:-.venv/bin/python}"
BUILDER_NAME="200feet-builder"
if [[ $# -ge 1 ]]; then
TAG="$1"
else
DATE_TAG="$(date +%Y%m%d-%H%M)"
if git rev-parse --short HEAD >/dev/null 2>&1; then
SHA="$(git rev-parse --short HEAD)"
TAG="${DATE_TAG}-${SHA}"
else
TAG="${DATE_TAG}"
fi
fi
if [[ "${SKIP_GEN:-0}" != "1" ]]; then
echo "==> Regenerating analysis"
"$PY" scripts/analyze.py
"$PY" scripts/compare_setbacks.py
"$PY" scripts/build_web.py
fi
# Ensure a buildx builder exists with multi-arch / QEMU support. Reuse any
# existing docker-container builder rather than always creating ours -- many
# M1 setups already have one (e.g. 'multiarch' under Colima).
PICKED_BUILDER=""
if docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
PICKED_BUILDER="${BUILDER_NAME}"
else
# Try first existing docker-container builder, else create ours.
while IFS= read -r line; do
name=$(awk '{print $1}' <<<"$line" | sed 's/\*$//')
driver=$(awk '{print $2}' <<<"$line")
if [[ "$driver" == "docker-container" && -n "$name" && "$name" != "NAME/NODE" ]]; then
PICKED_BUILDER="$name"; break
fi
done < <(docker buildx ls 2>/dev/null | tail -n +2)
if [[ -z "${PICKED_BUILDER}" ]]; then
echo "==> Creating buildx builder '${BUILDER_NAME}'"
docker buildx create --name "${BUILDER_NAME}" --driver docker-container >/dev/null
docker buildx inspect "${BUILDER_NAME}" --bootstrap >/dev/null
PICKED_BUILDER="${BUILDER_NAME}"
fi
fi
docker buildx use "${PICKED_BUILDER}"
echo "==> Using buildx builder '${PICKED_BUILDER}'"
# Sanity-check the builder actually supports the requested platforms.
SUPPORTED=$(docker buildx inspect "${PICKED_BUILDER}" 2>/dev/null | awk -F: '/Platforms/ {print $2}' | tr -d ' ')
for plat in $(echo "${PLATFORMS}" | tr ',' ' '); do
if ! grep -q "${plat}" <<<"${SUPPORTED}"; then
echo "ERROR: builder '${PICKED_BUILDER}' does not support ${plat}." >&2
echo "Supported: ${SUPPORTED}" >&2
exit 1
fi
done
echo "==> Building & pushing ${IMAGE}:${TAG} for ${PLATFORMS}"
docker buildx build \
--builder "${PICKED_BUILDER}" \
--platform "${PLATFORMS}" \
--provenance=false \
--sbom=false \
--tag "${IMAGE}:${TAG}" \
--tag "${IMAGE}:latest" \
--push \
.
echo
echo "==> Verifying pushed manifest"
docker buildx imagetools inspect "${IMAGE}:latest"
echo
echo "Pushed:"
echo " ${IMAGE}:${TAG}"
echo " ${IMAGE}:latest"
echo "Platforms: ${PLATFORMS}"
echo
echo "In Portainer: Stacks -> 200feet -> Update -> 'Re-pull image and redeploy'."
+107
View File
@@ -0,0 +1,107 @@
"""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())
+225
View File
@@ -0,0 +1,225 @@
"""Compare 200ft vs 500ft (vs 1000ft) setbacks in terms that matter for
political argument: how many homes get protected, and which DC sites disappear.
Outputs:
out/comparison_summary.txt plain-text headline numbers
out/homes_exposure.csv per-dwelling distance to nearest DC patch
out/marginal_parcels_500.geojson parcels eligible at 200 but NOT at 500
out/marginal_parcels_1000.geojson parcels eligible at 200 but NOT at 1000
web/data/comparison.json consumed by the web page for live display
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
import geopandas as gpd
import pandas as pd
from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon
from shapely.strtree import STRtree
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
OUT = ROOT / "out"
WEB = ROOT / "web" / "data"
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
RESIDENTIAL_PLACETYPES = {
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
}
# Distance buckets reported in the summary table (ft). 5280 = 1 mile.
EXPOSURE_BUCKETS = [200, 500, 1000, 1500, 2000, 2640, 5280]
def fix(g):
if g is None or g.is_empty:
return g
return g if g.is_valid else make_valid(g)
def stamp(msg, t0=None):
now = time.time()
if t0 is None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
else:
print(f"[{time.strftime('%H:%M:%S')}] {msg} ({now - t0:.1f}s)", flush=True)
return now
def main() -> int:
t0 = stamp("loading layers")
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
crs = parcels.crs
# Eligible (buildable + frontage-validated) GeoJSONs from analyze.py.
# These are in EPSG:4326; reproject back to EPSG:2274 for distance math.
elig = {}
for ft in (200, 500, 1000):
p = OUT / f"eligible_{ft}ft_buildable.geojson"
if p.exists() and p.stat().st_size > 100:
g = gpd.read_file(p).to_crs(crs)
elig[ft] = g
else:
elig[ft] = gpd.GeoDataFrame({"geometry": []}, geometry="geometry", crs=crs)
stamp("loaded", t0)
for ft, g in elig.items():
print(f" buildable@{ft}ft: {len(g)} parcels")
# ----- Residential dwellings = address points with residential PLACETYPE
t1 = stamp("identifying dwellings")
res_mask = addresses["PLACETYPE"].astype(str).str.upper().isin(RESIDENTIAL_PLACETYPES)
homes = addresses[res_mask].copy()
print(f" dwelling address points: {len(homes):,}")
stamp("done", t1)
# ----- For each home, distance (ft) to nearest buildable patch per setback
summary = {}
home_dist_cols = {}
for ft in (200, 500, 1000):
t1 = stamp(f"computing per-home distance to nearest {ft}ft buildable patch")
if len(elig[ft]) == 0:
home_dist_cols[ft] = [float("inf")] * len(homes)
stamp(" (no eligible patches)", t1)
continue
tree = STRtree(elig[ft].geometry.values)
polys = elig[ft].geometry.values
dists = []
for pt in homes.geometry.values:
# nearest polygon, then exact distance from point to that polygon.
i = tree.nearest(pt)
d = polys[i].distance(pt)
dists.append(d)
home_dist_cols[ft] = dists
stamp("done", t1)
homes["dist_200_ft"] = home_dist_cols[200]
homes["dist_500_ft"] = home_dist_cols[500]
homes["dist_1000_ft"] = home_dist_cols[1000]
# ----- Exposure buckets per setback
for ft in (200, 500, 1000):
d = homes[f"dist_{ft}_ft"]
summary[f"setback_{ft}"] = {
"buildable_patches": int(len(elig[ft])),
"homes_within_ft": {
str(b): int((d <= b).sum()) for b in EXPOSURE_BUCKETS
},
}
# ----- Homes "protected" by tightening from 200 -> 500 (or -> 1000)
# Definition: home is within X ft of any patch at 200ft setback, but is
# >= X ft from any patch at 500ft setback. I.e., the patches that pushed
# the home into the exposure ring are exactly the ones that go away at
# the tighter setback.
protection = {}
for tighter in (500, 1000):
rows = {}
for b in EXPOSURE_BUCKETS:
exposed_at_200 = homes["dist_200_ft"] <= b
still_exposed = homes[f"dist_{tighter}_ft"] <= b
rows[str(b)] = int((exposed_at_200 & ~still_exposed).sum())
protection[f"to_{tighter}"] = {
"newly_protected_within_ft": rows,
}
summary["protection"] = protection
# ----- Homes newly protected: within 1000ft of a possible DC at 200ft
# setback, but >= 1000ft at the tighter setback. The 1000ft threshold is
# the headline "close-range exposure" distance.
PROTECT_BUCKET_FT = 1000
for tighter in (500, 1000):
exposed_200 = homes["dist_200_ft"] <= PROTECT_BUCKET_FT
still_exposed = homes[f"dist_{tighter}_ft"] <= PROTECT_BUCKET_FT
newly = homes[exposed_200 & ~still_exposed].copy()
# Reproject to EPSG:4326 for the web
newly_web = newly.to_crs("EPSG:4326")
keep_cols = ["FULLADDR", "PLACETYPE", "dist_200_ft", f"dist_{tighter}_ft"]
newly_web = newly_web[[c for c in keep_cols if c in newly_web.columns] + ["geometry"]]
out_p = OUT / f"homes_protected_{tighter}.geojson"
if out_p.exists():
out_p.unlink()
if len(newly_web):
newly_web.to_file(out_p, driver="GeoJSON")
print(f" homes newly protected -> {tighter}ft (within {PROTECT_BUCKET_FT}ft): {len(newly_web)}")
# ----- Marginal parcels: eligible at 200 but lost at 500 / 1000
e200 = elig[200]
for tighter in (500, 1000):
et = elig[tighter]
tighter_ids = set(et["GISLINK"].dropna().astype(str).tolist()) if "GISLINK" in et.columns else set()
if "GISLINK" not in e200.columns:
marginal = gpd.GeoDataFrame(columns=e200.columns)
else:
marginal = e200[~e200["GISLINK"].astype(str).isin(tighter_ids)].copy()
out_p = OUT / f"marginal_parcels_{tighter}.geojson"
if out_p.exists():
out_p.unlink()
if len(marginal):
marginal.to_file(out_p, driver="GeoJSON")
print(f" marginal at -> {tighter}ft: {len(marginal)} parcels lost")
summary[f"marginal_to_{tighter}"] = int(len(marginal))
# ----- Write outputs
OUT.mkdir(exist_ok=True)
homes_out = homes[["FULLADDR", "PLACETYPE", "dist_200_ft", "dist_500_ft", "dist_1000_ft"]].copy()
for c in ("dist_200_ft", "dist_500_ft", "dist_1000_ft"):
homes_out[c] = homes_out[c].round(1)
homes_out.to_csv(OUT / "homes_exposure.csv", index=False)
print(f" wrote out/homes_exposure.csv ({len(homes_out):,} rows)")
# Plain-text headline
with open(OUT / "comparison_summary.txt", "w") as f:
f.write("Johnson City data-center setback comparison\n")
f.write(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"Dwelling address points considered: {len(homes):,}\n\n")
f.write("Buildable-subdividable patches:\n")
for ft in (200, 500, 1000):
f.write(f" {ft:>4} ft setback: {summary[f'setback_{ft}']['buildable_patches']:>4} patches\n")
f.write("\nHomes within distance D of any potential data center:\n")
f.write(f" {'D (ft)':>8} | {'200ft':>8} | {'500ft':>8} | {'1000ft':>8}\n")
f.write(" " + "-" * 44 + "\n")
for b in EXPOSURE_BUCKETS:
r = [summary[f"setback_{ft}"]["homes_within_ft"][str(b)] for ft in (200, 500, 1000)]
f.write(f" {b:>8,} | {r[0]:>8,} | {r[1]:>8,} | {r[2]:>8,}\n")
f.write("\nHomes NEWLY PROTECTED by tightening setback from 200ft:\n")
f.write(f" {'D (ft)':>8} | {'200->500':>10} | {'200->1000':>10}\n")
f.write(" " + "-" * 36 + "\n")
for b in EXPOSURE_BUCKETS:
a = protection["to_500"]["newly_protected_within_ft"][str(b)]
c = protection["to_1000"]["newly_protected_within_ft"][str(b)]
f.write(f" {b:>8,} | {a:>10,} | {c:>10,}\n")
f.write(f"\nMarginal DC sites lost by tightening 200 -> 500: {summary['marginal_to_500']}\n")
f.write(f"Marginal DC sites lost by tightening 200 -> 1000: {summary['marginal_to_1000']}\n")
print(f" wrote out/comparison_summary.txt")
print("\n--- comparison_summary.txt ---")
print((OUT / "comparison_summary.txt").read_text())
# JSON for the web page
WEB.mkdir(parents=True, exist_ok=True)
with open(WEB / "comparison.json", "w") as f:
json.dump({
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"total_homes": int(len(homes)),
"buckets_ft": EXPOSURE_BUCKETS,
**summary,
}, f, indent=2)
print(f" wrote web/data/comparison.json")
# Copy comparison artifacts into web/
for stem in ("marginal_parcels_500", "marginal_parcels_1000",
"homes_protected_500", "homes_protected_1000"):
src = OUT / f"{stem}.geojson"
if src.exists():
dst = WEB / src.name
dst.write_bytes(src.read_bytes())
print(f" copied -> web/data/{src.name}")
return 0
if __name__ == "__main__":
sys.exit(main())
+152
View File
@@ -0,0 +1,152 @@
"""Diagnose why a specific parcel was/wasn't included in the buildable list.
Usage: scripts/diagnose_parcel.py [GISLINK]
Default: 090063 00203 (BrightRidge / Innovation Dr 300)
"""
from __future__ import annotations
import sys
from pathlib import Path
import geopandas as gpd
import pandas as pd
from shapely import make_valid, unary_union
from shapely.ops import polylabel
from shapely.geometry import MultiPolygon, Polygon
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
RESIDENTIAL_DISTRICT_ZONECLASSES = {
"R-1", "R-2", "R-2A", "R-2B", "R-2C", "R-3", "R-4", "R-5", "R-6",
"RP-2", "RP-3", "RP-4", "RP-5", "RP-6",
"RO-1", "RO-2", "A-1", "RM-3", "RM-4", "RM-5",
}
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
RESIDENTIAL_PLACETYPES = {
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
}
VACANT_PLACETYPES = {"VACANT LOT", "VACANT", "VACANT UNIT"}
DC_INTERNAL_SETBACK_FT = 50
def fix(g):
if g is None or g.is_empty: return g
return g if g.is_valid else make_valid(g)
def landuse_is_residential(x):
if x is None or (isinstance(x, float) and pd.isna(x)): return False
prefix = str(x).split(" - ", 1)[0]
return bool({c.strip() for c in prefix.split(":") if c.strip()} & RESIDENTIAL_LANDUSE_CODES)
def inscribed_radius(geom, tol=2.0):
if geom is None or geom.is_empty: return 0.0
polys = []
if isinstance(geom, Polygon): polys = [geom]
elif isinstance(geom, MultiPolygon): polys = list(geom.geoms)
best = 0.0
for p in polys:
if p.is_empty or p.area < 1: continue
try:
pt = polylabel(p, tolerance=tol)
r = p.exterior.distance(pt)
if r > best: best = r
except Exception:
continue
return best
def main():
target_gid = sys.argv[1] if len(sys.argv) > 1 else "090063 00203"
print(f"Diagnosing parcel: '{target_gid}'\n")
zoning = gpd.read_parquet(CACHE / "zoning.parquet")
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
zoning["geometry"] = zoning.geometry.apply(fix)
parcels["geometry"] = parcels.geometry.apply(fix)
parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
target = parcels[parcels["GISLINK"].astype(str) == target_gid]
if target.empty:
print(f"No parcel found with GISLINK '{target_gid}'")
return
tgt = target.iloc[0]
tg = tgt.geometry
print(f"Owner: {tgt.OWNER} Address: {tgt.ADDRESS}")
print(f"Zone: {tgt.ZONECLASS} LANDUSE: {tgt.LANDUSE}")
print(f"Parcel acres: {tg.area/43560:.2f}")
# Build residential exclusion (vacant-override rule)
addr_pt = addresses["PLACETYPE"].astype(str).str.upper()
res_addrs = addresses[addr_pt.isin(RESIDENTIAL_PLACETYPES)]
vac_addrs = addresses[addr_pt.isin(VACANT_PLACETYPES)]
sj_res = gpd.sjoin(res_addrs[["geometry"]], parcels[["GISLINK","geometry"]], how="inner", predicate="within")
res_gids = set(sj_res["GISLINK"].dropna().unique())
sj_vac = gpd.sjoin(vac_addrs[["geometry"]], parcels[["GISLINK","geometry"]], how="inner", predicate="within")
vac_gids = set(sj_vac["GISLINK"].dropna().unique())
override = vac_gids - res_gids
res_lu_mask = parcels["LANDUSE"].apply(landuse_is_residential) & ~parcels["GISLINK"].isin(override)
res_lu_parcels = parcels[res_lu_mask]
res_addr_parcels = parcels[parcels["GISLINK"].isin(res_gids)]
zc = zoning["ZONECLASS"].fillna("").astype(str)
res_zones = zoning[zc.isin(RESIDENTIAL_DISTRICT_ZONECLASSES)]
i2_union = unary_union(zoning[zoning["ZONECLASS"]=="I-2"].geometry.values)
# I-2 portion of this parcel
i2_part = fix(tg.intersection(i2_union))
print(f"\nI-2 portion of parcel: {i2_part.area/43560:.2f} acres ({i2_part.area:.0f} sqft)")
# Residential exclusion sources WITHIN 1500ft of the parcel
buf_test = tg.buffer(1500)
nearby_res_zones = res_zones[res_zones.intersects(buf_test)]
nearby_res_lu = res_lu_parcels[res_lu_parcels.intersects(buf_test)]
nearby_res_addr = res_addr_parcels[res_addr_parcels.intersects(buf_test)]
print(f"\nResidential sources within 1500ft of parcel:")
print(f" residential zone polygons: {len(nearby_res_zones)}")
print(f" parcels with residential LU: {len(nearby_res_lu)}")
print(f" parcels with dwelling addr: {len(nearby_res_addr)}")
# Compute the residential exclusion union limited to nearby (faster)
nearby_geoms = list(nearby_res_zones.geometry.values) + \
list(nearby_res_lu.geometry.values) + \
list(nearby_res_addr.geometry.values)
if nearby_geoms:
nearby_union = unary_union(nearby_geoms)
else:
nearby_union = None
for setback in (200, 500, 1000):
if nearby_union is None:
buf = None
remnant = i2_part
else:
buf = nearby_union.buffer(setback)
remnant = fix(i2_part.difference(buf))
eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT)) if not remnant.is_empty else remnant
ir_remnant = inscribed_radius(remnant)
ir_eroded = inscribed_radius(eroded)
# Compute frontage
ps_idx = parcels.sindex
nearby_ids = list(ps_idx.query(tg.buffer(10)))
nearby_parcels = [parcels.iloc[i].geometry for i in nearby_ids
if parcels.iloc[i].geometry is not None
and not parcels.iloc[i].geometry.equals(tg)]
if nearby_parcels:
nbr_union = unary_union([n.buffer(5) for n in nearby_parcels])
parent_road = tg.boundary.difference(nbr_union)
else:
parent_road = tg.boundary
patch_frontage = 0.0
if not remnant.is_empty and not parent_road.is_empty:
front = remnant.boundary.intersection(parent_road.buffer(5))
patch_frontage = front.length if not front.is_empty else 0.0
print(f"\n@ {setback} ft setback:")
print(f" compliant remnant: {remnant.area/43560:.3f} ac ({remnant.area:.0f} sqft)")
print(f" inscribed diam: {2*ir_remnant:.1f} ft")
print(f" eroded (siteable): {eroded.area/43560:.3f} ac ({eroded.area:.0f} sqft)")
print(f" inscribed diam: {2*ir_eroded:.1f} ft")
print(f" patch road frontage: {patch_frontage:.1f} ft (parent has {parent_road.length:.1f} ft total)")
passes_filters = (remnant.area >= 5000 and 2*ir_eroded >= 50 and patch_frontage >= 50)
print(f" PASSES filters? {passes_filters}")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""One-time prep: load GeoJSONs, reproject to TN State Plane US Feet, cache to parquet."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import geopandas as gpd
ROOT = Path(__file__).resolve().parent.parent
GIS = ROOT / "gis"
CACHE = ROOT / "cache"
CACHE.mkdir(exist_ok=True)
TN_FT = "EPSG:2274" # NAD83 / Tennessee State Plane, US Survey Feet
def prep(name: str, src: Path, keep_cols: list[str]) -> None:
out = CACHE / f"{name}.parquet"
if out.exists():
print(f"[skip] {name} already cached -> {out}")
return
t0 = time.time()
print(f"[read] {src.name} ...", flush=True)
gdf = gpd.read_file(src)
print(f" loaded {len(gdf):,} features in {time.time()-t0:.1f}s; crs={gdf.crs}")
keep_cols = [c for c in keep_cols if c in gdf.columns]
gdf = gdf[keep_cols + ["geometry"]]
t1 = time.time()
print(f"[reproject] -> {TN_FT}", flush=True)
gdf = gdf.to_crs(TN_FT)
# Drop Z dimension if present (force XY)
try:
gdf["geometry"] = gpd.GeoSeries(
[g if g is None or not g.has_z else _drop_z(g) for g in gdf.geometry],
crs=gdf.crs,
)
except Exception:
pass
print(f" reprojected in {time.time()-t1:.1f}s")
gdf.to_parquet(out)
print(f"[wrote] {out} ({out.stat().st_size/1e6:.1f} MB)")
def _drop_z(geom):
from shapely.ops import transform
return transform(lambda x, y, z=None: (x, y), geom)
def main() -> None:
prep(
"zoning",
GIS / "Zoning.geojson",
keep_cols=["OBJECTID", "ZONECLASS", "ZONEDESC", "ACRES_CALC"],
)
prep(
"parcels",
GIS / "Parcels.geojson",
keep_cols=[
"OBJECTID", "PARID", "GISLINK", "ADDRESS", "STREET",
"OWNER", "OWNER2", "ZONECLASS", "ZONEDESC",
"LANDUSE", "PROPTYPE", "CALC_ACRE",
],
)
prep(
"addresses",
GIS / "Addresses.geojson",
keep_cols=[
"OBJECTID", "GISLINK", "FULLADDR", "PLACETYPE",
"ADDRCLASS", "ZONING", "OWNER_CALC",
],
)
prep(
"roads",
GIS / "Road_Centerlines.geojson",
keep_cols=[
"OBJECTID", "CENTERLINEID", "FULLNAME", "ROADCLASS",
"ROADCLASS_PW", "MAINTBY", "PRIVDRIVETYPE",
],
)
if __name__ == "__main__":
sys.exit(main())
+154
View File
@@ -0,0 +1,154 @@
"""Sweep min-building-footprint thresholds across all setback distances and
write a single matrix to out/sweep_matrix.txt. Loads the data once; reuses
the buffered residential exclusion and per-parcel buildable polygons across
all thresholds. Does NOT touch the canonical eligible_*.csv files.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import geopandas as gpd
from shapely import make_valid, unary_union
from shapely.geometry import MultiPolygon, Polygon
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
OUT = ROOT / "out"
# Mirror the constants and helpers from analyze.py.
DC_INTERNAL_SETBACK_FT = 50
SETBACKS_FT = [200, 500, 1000]
BUILDING_THRESHOLDS = [5_000, 20_000, 50_000, 100_000, 250_000, 500_000]
RESIDENTIAL_DISTRICT_ZONECLASSES = {
"R-1", "R-2", "R-2A", "R-2B", "R-2C", "R-3", "R-4", "R-5", "R-6",
"RP-2", "RP-3", "RP-4", "RP-5", "RP-6",
"RO-1", "RO-2", "A-1", "RM-3", "RM-4", "RM-5",
}
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}
RESIDENTIAL_PLACETYPES = {
"SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
"CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
}
def fix(g):
if g is None or g.is_empty:
return g
return g if g.is_valid else make_valid(g)
def max_poly(g) -> float:
if g is None or g.is_empty:
return 0.0
if isinstance(g, Polygon):
return g.area
if isinstance(g, MultiPolygon):
return max((p.area for p in g.geoms), default=0.0)
try:
return max((max_poly(p) for p in g.geoms), default=0.0)
except AttributeError:
return 0.0
def landuse_res(s) -> bool:
if s is None or (isinstance(s, float) and s != s):
return False
prefix = str(s).split(" - ", 1)[0]
return bool({c.strip() for c in prefix.split(":")} & RESIDENTIAL_LANDUSE_CODES)
def main() -> int:
t0 = time.time()
print("loading layers...", flush=True)
zoning = gpd.read_parquet(CACHE / "zoning.parquet")
parcels = gpd.read_parquet(CACHE / "parcels.parquet")
addresses = gpd.read_parquet(CACHE / "addresses.parquet")
zoning["geometry"] = zoning.geometry.apply(fix)
parcels["geometry"] = parcels.geometry.apply(fix)
parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
i2 = unary_union(zoning[zoning["ZONECLASS"] == "I-2"].geometry.values)
cand_idx = sorted(parcels.sindex.query(i2, predicate="intersects"))
cands = parcels.iloc[cand_idx].copy()
cands["i2_geom"] = cands.geometry.intersection(i2).apply(fix)
cands = cands[~cands["i2_geom"].is_empty].copy()
print(f" I-2 candidate parcels: {len(cands):,}")
res_zones = zoning[zoning["ZONECLASS"].isin(RESIDENTIAL_DISTRICT_ZONECLASSES)]
res_landuse = parcels[parcels["LANDUSE"].apply(landuse_res)]
res_addr_mask = addresses["PLACETYPE"].astype(str).str.upper().isin(RESIDENTIAL_PLACETYPES)
res_addrs = addresses[res_addr_mask]
sj = gpd.sjoin(res_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
how="inner", predicate="within")
res_addr_par = parcels[parcels["GISLINK"].isin(set(sj["GISLINK"].dropna().unique()))]
res_union = unary_union(
list(res_zones.geometry.values)
+ list(res_landuse.geometry.values)
+ list(res_addr_par.geometry.values)
)
print(f" residential union built ({time.time()-t0:.1f}s)")
# results[(setback_ft, building_sqft)] -> count
results = {}
strict_counts = {}
for ft in SETBACKS_FT:
t1 = time.time()
excl = res_union.buffer(ft)
sindex = gpd.GeoSeries([excl], crs=parcels.crs).sindex
strict = 0
siteable_per_parcel = []
for row in cands.itertuples(index=False):
parcel_g = row.geometry
i2g = row.i2_geom
hits = list(sindex.query(parcel_g, predicate="intersects"))
if not hits:
strict += 1
eroded = fix(i2g.buffer(-DC_INTERNAL_SETBACK_FT))
else:
remnant = fix(i2g.difference(excl))
if remnant.is_empty:
siteable_per_parcel.append(0.0)
continue
eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT))
siteable_per_parcel.append(max_poly(eroded))
strict_counts[ft] = strict
for thr in BUILDING_THRESHOLDS:
results[(ft, thr)] = sum(1 for s in siteable_per_parcel if s >= thr)
print(f" {ft}ft done strict={strict} ({time.time()-t1:.1f}s)")
# Pretty matrix
OUT.mkdir(exist_ok=True)
out_path = OUT / "sweep_matrix.txt"
with open(out_path, "w") as f:
f.write("Johnson City Data Center site-eligibility sweep\n")
f.write(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"I-2 candidate parcels: {len(cands):,}\n")
f.write(f"Setbacks: {SETBACKS_FT} (200 = ordinance, others exploratory)\n")
f.write(f"DC internal yard setback: {DC_INTERNAL_SETBACK_FT}ft (side/rear)\n")
f.write("\nStrict eligibility (entire parcel >= setback from residential):\n")
for ft in SETBACKS_FT:
f.write(f" {ft:>4} ft: {strict_counts[ft]:>4}\n")
f.write("\nSite-able count by min DC BUILDING footprint (sqft) x setback (ft):\n")
header = " building sqft |" + "".join(f" {ft:>5} ft" for ft in SETBACKS_FT) + "\n"
f.write(header)
f.write(" " + "-" * (len(header) - 3) + "\n")
for thr in BUILDING_THRESHOLDS:
row = f" {thr:>13,} |"
for ft in SETBACKS_FT:
row += f" {results[(ft, thr)]:>8}"
f.write(row + "\n")
f.write("\nNotes:\n")
f.write(" - Site-able = largest contiguous compliant patch eroded by 50ft\n"
" (the DC's own side/rear yard per draft 6.20.3.2.I).\n"
" - Counts are NECESSARY-but-not-sufficient: every parcel still\n"
" needs BZA special-exception approval.\n")
print(f"\nwrote {out_path}")
print("\n--- sweep_matrix.txt ---")
print(out_path.read_text())
return 0
if __name__ == "__main__":
sys.exit(main())
+457
View File
@@ -0,0 +1,457 @@
/* 200feet.net — Johnson City data-center setback explorer.
Pure static. Renders eligible parcels on a Leaflet map and lets users
measure how close a data center could legally be to any JC address. */
// Bumped whenever the data payload schema changes -- forces browsers to
// re-fetch JSON/GeoJSON instead of using stale cached copies.
const DATA_V = "8";
const v = (url) => url + (url.includes("?") ? "&" : "?") + "v=" + DATA_V;
// Palette mirrors the CSS :root variables. Keep in sync with style.css.
const C = {
i2: "#3b82f6", // I-2 zoning
strict: "#ef4444", // strict-eligible parcels (urgent red)
buildable: "#fbbf24", // buildable patches (amber)
siteable: "#f97316", // building envelope (deeper orange)
protected: "#22c55e", // homes newly protected (green)
marginal: "#c084fc", // DC sites lost (purple)
you: "#06b6d4", // your address (cyan)
};
const STYLES = {
i2zone: { color: C.i2, weight: 1, fillColor: C.i2, fillOpacity: 0.15 },
i2parcel: { color: "#98a1b3", weight: 0.5, fillOpacity: 0 },
strict: { color: C.strict, weight: 2.5, fillColor: C.strict, fillOpacity: 0.35 },
buildable: { color: C.buildable, weight: 1, fillColor: C.buildable, fillOpacity: 0.45 },
siteable: { color: C.siteable, weight: 1, fillColor: C.siteable, fillOpacity: 0.55 },
marginal: { color: C.marginal, weight: 3, fillOpacity: 0, dashArray: "8 6" },
};
const POINT_STYLES = {
homesProtected: { radius: 4, fillColor: C.protected, color: "#15803d", weight: 1, fillOpacity: 0.80 },
};
const layerSpec = [
{ id: "layer-i2-zones", url: "data/i2_zones.geojson", style: STYLES.i2zone, popup: i2ZonePopup },
{ id: "layer-i2-parcels", url: "data/i2_parcels.geojson", style: STYLES.i2parcel, popup: parcelPopup },
{ id: "layer-buildable", url: "data/eligible_{ft}ft_buildable.geojson", style: STYLES.buildable, popup: buildablePopup, perFt: true },
{ id: "layer-siteable", url: "data/eligible_{ft}ft_siteable.geojson", style: STYLES.siteable, popup: buildablePopup, perFt: true },
{ id: "layer-strict", url: "data/eligible_{ft}ft_strict.geojson", style: STYLES.strict, popup: strictPopup, perFt: true },
{ id: "layer-marginal", url: "data/marginal_parcels_500.geojson", style: STYLES.marginal, popup: marginalPopup, alwaysTop: true },
{ id: "layer-homes-protected", url: "data/homes_protected_500.geojson", pointStyle: POINT_STYLES.homesProtected, popup: protectedHomePopup, alwaysTop: true },
];
const map = L.map("map", { center: [36.3134, -82.3535], zoom: 12 });
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const state = {
ft: 200,
layers: {},
geojsonCache: {},
addresses: null,
addressesLoading: null,
userMarker: null,
nearestHighlight: null,
};
/* ---------- popup formatters ---------- */
function fmtAcres(a) { return (a == null) ? "—" : a.toFixed(2) + " ac"; }
function fmtFt(n) { return (n == null) ? "—" : Math.round(n).toLocaleString() + " ft"; }
function i2ZonePopup(p) { return `<b>${p.ZONECLASS || "I-2"}</b><br>${p.ZONEDESC || ""}`; }
function parcelPopup(p) {
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br>Zone: ${p.ZONECLASS || "—"} · Land use: ${p.LANDUSE || "—"}` +
`<br>Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}`;
}
function buildablePopup(p) {
const ft = state.ft;
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br>Largest contig patch: <b>${fmtAcres(p["max_contig_acres_" + ft])}</b>` +
`<br>Building envelope: ${fmtAcres(p["max_siteable_acres_" + ft])}` +
`<br>Min building width: <b>${fmtFt(p["inscribed_diam_ft_" + ft])}</b>` +
`<br>Public road frontage: <b>${fmtFt(p["patch_frontage_ft_" + ft])}</b>`;
}
function strictPopup(p) {
const ft = state.ft;
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br>Whole parcel passes the ${ft} ft test.` +
`<br>Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}` +
`<br>Min building width: <b>${fmtFt(p["inscribed_diam_ft_" + ft])}</b>`;
}
function marginalPopup(p) {
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br><span style="color:${C.marginal}">Eligible at 200 ft. Lost if setback raised to 500 ft.</span>` +
`<br>Buildable @ 200 ft: ${fmtAcres(p.max_contig_acres_200)}`;
}
// Anonymize a house-level address to the 100-block to avoid individually
// fingerprinting a single home in the popup. "414 BRICE LN" -> "400 block of
// BRICE LN". Leaves addresses without a leading numeric house number alone.
function anonymizeAddress(addr) {
if (!addr) return "(no address)";
const m = String(addr).trim().match(/^(\d+)\s+(.+)$/);
if (!m) return String(addr);
const num = parseInt(m[1], 10);
if (!Number.isFinite(num) || num <= 0) return String(addr);
const block = Math.floor(num / 100) * 100;
return `${block} block of ${m[2]}`;
}
function protectedHomePopup(p) {
return `<b>${anonymizeAddress(p.FULLADDR)}</b>` +
`<br>Type: ${p.PLACETYPE || "—"}` +
`<br>Nearest DC site @ 200 ft setback: <b>${fmtFt(p.dist_200_ft)}</b>` +
`<br>Nearest DC site @ 500 ft setback: <b>${fmtFt(p.dist_500_ft)}</b>` +
`<br><span style="color:${C.protected}">Newly outside the 1,000 ft exposure ring if setback raised to 500 ft.</span>`;
}
/* ---------- data loading ---------- */
async function getGeoJSON(url) {
if (!state.geojsonCache[url]) {
const p = fetch(v(url)).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status} ${url}`)));
// Don't keep a rejected promise in cache -- retry on next call.
p.catch(() => { delete state.geojsonCache[url]; });
state.geojsonCache[url] = p;
}
return state.geojsonCache[url];
}
async function renderLayer(spec) {
if (state.layers[spec.id]) { map.removeLayer(state.layers[spec.id]); delete state.layers[spec.id]; }
const checkbox = document.getElementById(spec.id);
if (!checkbox || !checkbox.checked) return;
const url = spec.url.replace("{ft}", state.ft);
let gj;
try { gj = await getGeoJSON(url); }
catch (e) { console.warn("missing", url); return; }
const opts = {
onEachFeature: (feature, lyr) => lyr.bindPopup(spec.popup(feature.properties)),
};
if (spec.style) opts.style = spec.style;
if (spec.pointStyle) {
opts.pointToLayer = (feature, latlng) => L.circleMarker(latlng, spec.pointStyle);
}
const layer = L.geoJSON(gj, opts);
layer.addTo(map);
if (spec.alwaysTop) layer.bringToFront();
state.layers[spec.id] = layer;
}
async function renderAll() { for (const spec of layerSpec) await renderLayer(spec); }
/* ---------- controls ---------- */
function wireSetbackButtons() {
document.querySelectorAll(".seg-btn").forEach(btn => {
btn.addEventListener("click", async () => {
document.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.ft = parseInt(btn.dataset.ft, 10);
for (const spec of layerSpec) if (spec.perFt) await renderLayer(spec);
// Keep marginal layer on top after re-render
const mLayer = state.layers["layer-marginal"];
if (mLayer) mLayer.bringToFront();
if (state.userMarker) refreshDistance();
});
});
}
function wireLayerCheckboxes() {
layerSpec.forEach(spec => {
const cb = document.getElementById(spec.id);
if (cb) cb.addEventListener("change", () => renderLayer(spec));
});
// Mirror the case-section homes-protected toggle to the layer-menu one.
pairMirror("layer-homes-protected", "layer-homes-protected-2");
}
function pairMirror(primaryId, mirrorId) {
const primary = document.getElementById(primaryId);
const mirror = document.getElementById(mirrorId);
if (!primary || !mirror) return;
mirror.checked = primary.checked;
mirror.addEventListener("change", () => {
if (primary.checked !== mirror.checked) {
primary.checked = mirror.checked;
primary.dispatchEvent(new Event("change"));
}
});
primary.addEventListener("change", () => {
if (mirror.checked !== primary.checked) mirror.checked = primary.checked;
});
}
function wireLayerMenu() {
// The menu trigger lives inside <details>; the panel itself lives outside
// (as a sibling of the map) so opening the menu pushes the map down rather
// than overlapping it. We show/hide the panel based on the details state.
const menu = document.querySelector(".layer-menu");
const panel = document.getElementById("menu-panel");
if (!menu || !panel) return;
const sync = () => { panel.hidden = !menu.open; };
menu.addEventListener("toggle", sync);
sync();
}
/* ---------- address checker ---------- */
async function loadAddresses() {
if (state.addresses) return state.addresses;
if (state.addressesLoading) return state.addressesLoading;
state.addressesLoading = fetch(v("data/addresses.json")).then(r => r.json()).then(arr => {
state.addresses = arr.map(([a, lat, lng]) => [a, lat, lng, a.toLowerCase()]);
return state.addresses;
});
return state.addressesLoading;
}
const debounce = (fn, ms) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; };
const addrInput = document.getElementById("addr-input");
const addrSuggest = document.getElementById("addr-suggest");
const addrGo = document.getElementById("addr-go");
const addrResult = document.getElementById("addr-result");
let suggestActive = -1;
let suggestItems = [];
const onType = debounce(async (val) => {
if (!val || val.length < 2) { addrSuggest.hidden = true; return; }
const arr = await loadAddresses();
const q = val.toLowerCase();
const tokens = q.split(/\s+/).filter(Boolean);
const out = [];
for (let i = 0; i < arr.length && out.length < 12; i++) {
const lc = arr[i][3];
let ok = true;
for (const t of tokens) if (lc.indexOf(t) < 0) { ok = false; break; }
if (ok) out.push(arr[i]);
}
suggestItems = out;
if (!out.length) { addrSuggest.hidden = true; return; }
addrSuggest.innerHTML = out.map((row, i) => `<li data-i="${i}">${escapeHtml(row[0])}</li>`).join("");
addrSuggest.hidden = false;
suggestActive = -1;
}, 80);
addrInput.addEventListener("input", e => onType(e.target.value));
addrInput.addEventListener("keydown", e => {
if (addrSuggest.hidden) return;
if (e.key === "ArrowDown") { e.preventDefault(); moveSuggest(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); moveSuggest(-1); }
else if (e.key === "Enter") { e.preventDefault(); pickSuggest(suggestActive >= 0 ? suggestActive : 0); }
else if (e.key === "Escape") addrSuggest.hidden = true;
});
addrSuggest.addEventListener("click", e => {
const li = e.target.closest("li");
if (li) pickSuggest(parseInt(li.dataset.i, 10));
});
addrGo.addEventListener("click", () => { if (suggestItems[0]) pickSuggest(0); });
document.addEventListener("click", e => { if (!e.target.closest(".checker")) addrSuggest.hidden = true; });
function moveSuggest(delta) {
suggestActive = (suggestActive + delta + suggestItems.length) % suggestItems.length;
[...addrSuggest.children].forEach((li, i) => li.classList.toggle("active", i === suggestActive));
}
function pickSuggest(i) {
const row = suggestItems[i];
if (!row) return;
addrInput.value = row[0];
addrSuggest.hidden = true;
placeUser(row[0], row[1], row[2]);
}
function placeUser(addr, lat, lng) {
if (state.userMarker) map.removeLayer(state.userMarker);
state.userMarker = L.circleMarker([lat, lng], {
color: "#ffffff", weight: 2, fillColor: C.you, fillOpacity: 1, radius: 8,
}).bindPopup(`<b>${escapeHtml(addr)}</b>`).addTo(map);
map.setView([lat, lng], 15);
// Show a loading state immediately so the user knows something is happening.
addrResult.hidden = false;
addrResult.innerHTML = `<div class="big" style="color:var(--ink-dim)">Measuring…</div>`;
// Guarantee the loading state is replaced even if refreshDistance throws.
refreshDistance().catch(err => {
console.error("refreshDistance failed", err);
addrResult.innerHTML = `<div class="big">Error</div>
<div class="little">Couldn't measure distance: ${escapeHtml(err && err.message ? err.message : String(err))}.<br>
Open the browser console for details.</div>`;
});
}
// Distance (km) from a point to the nearest edge of a polygon feature.
// Handles Polygon, MultiPolygon, and GeometryCollection. Returns 0 if the
// point is inside the polygon.
function distancePointToFeatureKm(point, feature) {
if (!feature || !feature.geometry) return Infinity;
const gtype = feature.geometry.type;
if (gtype === "GeometryCollection") {
let min = Infinity;
for (const g of feature.geometry.geometries) {
const d = distancePointToFeatureKm(point, { type: "Feature", geometry: g, properties: feature.properties });
if (d < min) min = d;
}
return min;
}
if (gtype === "MultiPolygon") {
let min = Infinity;
for (const polyCoords of feature.geometry.coordinates) {
const polyFeat = turf.polygon(polyCoords, feature.properties);
const d = distancePointToFeatureKm(point, polyFeat);
if (d < min) min = d;
}
return min;
}
if (gtype !== "Polygon") return Infinity;
try {
if (turf.booleanPointInPolygon(point, feature)) return 0;
// polygonToLine on a Polygon returns Feature<LineString> (outer ring) for
// simple polygons, or Feature<MultiLineString> if there are holes. Both
// are valid inputs to pointToLineDistance.
const line = turf.polygonToLine(feature);
// turf 7 polygonToLine sometimes returns a FeatureCollection -- normalize.
if (line.type === "FeatureCollection") {
let min = Infinity;
for (const f of line.features) {
const d = turf.pointToLineDistance(point, f, { units: "kilometers" });
if (d < min) min = d;
}
return min;
}
return turf.pointToLineDistance(point, line, { units: "kilometers" });
} catch (e) {
console.warn("distance fallback for feature", e);
// Manual fallback: nearest vertex.
let min = Infinity;
turf.coordEach(feature, (coord) => {
const d = turf.distance(point, turf.point(coord), { units: "kilometers" });
if (d < min) min = d;
});
return min;
}
}
async function refreshDistance() {
const m = state.userMarker;
if (!m) return;
const userPoint = turf.point([m.getLatLng().lng, m.getLatLng().lat]);
// Compute nearest at all three setbacks so the user gets a comparison.
const setbacks = [200, 500, 1000];
const results = {};
for (const ft of setbacks) {
let gj;
try { gj = await getGeoJSON(`data/eligible_${ft}ft_buildable.geojson`); }
catch (e) { results[ft] = { empty: true }; continue; }
if (!gj || !gj.features || !gj.features.length) { results[ft] = { empty: true }; continue; }
let best = null;
for (const feat of gj.features) {
let d;
try { d = distancePointToFeatureKm(userPoint, feat); }
catch (err) { console.warn("skipped feature", err); continue; }
if (best === null || d < best.d) best = { d, feat };
}
results[ft] = best ? { ftDist: best.d * 3280.84, feat: best.feat } : { empty: true };
}
const active = results[state.ft];
addrResult.hidden = false;
// Headline section -- nearest at the currently selected setback
let headline;
if (!active) {
headline = `<div class="big" style="color:var(--ink-dim)">No data</div>`;
} else if (active.empty) {
headline = `<div class="big">No sites at ${state.ft} ft</div>
<div class="little">A ${state.ft} ft setback eliminates every possible data center site in Johnson City.</div>`;
} else if (active.ftDist === 0) {
const p = active.feat.properties;
headline = `<div class="big">Inside a buildable site</div>
<div class="little">Your address sits within an area where a data center could legally be sited under a ${state.ft} ft setback.<br>
Patch: <b>${escapeHtml(p.ADDRESS || "(no address)")}</b> · owner: <b>${escapeHtml(p.OWNER || "—")}</b>.</div>`;
} else {
const ft = active.ftDist;
const p = active.feat.properties;
const miles = ft >= 5280 ? ` <span class="muted">(${(ft/5280).toFixed(2)} mi)</span>` : "";
headline = `<div class="big">${Math.round(ft).toLocaleString()} ft${miles}</div>
<div class="little">to the nearest place a data center could legally be sited under a <b>${state.ft} ft</b> setback.<br>
Nearest site: <b>${escapeHtml(p.ADDRESS || "(no address)")}</b> · owner: <b>${escapeHtml(p.OWNER || "—")}</b>.</div>`;
}
// Comparison row: nearest at each setback so the user sees the change.
const cells = setbacks.map(ft => {
const r = results[ft];
let v;
if (!r) v = "—";
else if (r.empty) v = '<span class="muted">none</span>';
else if (r.ftDist === 0) v = "inside";
else v = `${Math.round(r.ftDist).toLocaleString()} ft`;
const cls = ft === state.ft ? "cmp-cell active" : "cmp-cell";
return `<div class="${cls}"><div class="cmp-lbl">${ft} ft setback</div><div class="cmp-val">${v}</div></div>`;
}).join("");
const compare = `<div class="cmp-row">${cells}</div>`;
addrResult.innerHTML = headline + compare;
// Highlight the nearest site for the currently selected setback.
if (state.nearestHighlight) { map.removeLayer(state.nearestHighlight); state.nearestHighlight = null; }
if (active && !active.empty && active.feat) {
state.nearestHighlight = L.geoJSON(active.feat, {
style: { color: C.you, weight: 4, fillColor: C.you, fillOpacity: 0.20 },
}).addTo(map);
}
}
/* ---------- headlines / comparison ---------- */
async function loadHeadlines() {
const m = await (await fetch(v("data/manifest.json"))).json();
const h = m.headlines || {};
document.getElementById("stat-strict-200").textContent = h["200ft_strict"] ?? "—";
document.getElementById("stat-buildable-200").textContent = h["200ft_buildable"] ?? "—";
}
async function loadComparison() {
let c;
try { c = await (await fetch(v("data/comparison.json"))).json(); }
catch (e) { console.warn("no comparison.json"); return; }
const s200 = c.setback_200, s500 = c.setback_500, s1000 = c.setback_1000;
const prot500 = c.protection.to_500.newly_protected_within_ft;
// Drop the 200ft bucket (all zeros by construction at every setback).
const buckets = c.buckets_ft.filter(b => b >= 500);
const fmt = n => n.toLocaleString();
const cell = (n) => `<td${n === 0 ? ' class="zero"' : ""}>${fmt(n)}</td>`;
const labelFor = b => b >= 5280 ? "1 mile"
: b >= 2640 ? "½ mile"
: `${b.toLocaleString()} ft`;
const tbody = document.querySelector("#case-table tbody");
tbody.innerHTML = buckets.map(b => {
const e200 = s200.homes_within_ft[b] ?? 0;
const e500 = s500.homes_within_ft[b] ?? 0;
const e1000 = s1000.homes_within_ft[b] ?? 0;
return `<tr><td>${labelFor(b)}</td>${cell(e200)}${cell(e500)}${cell(e1000)}</tr>`;
}).join("");
document.getElementById("case-remaining-500").textContent = s500.buildable_patches;
document.getElementById("case-remaining-1000").textContent = s1000.buildable_patches;
const homes1k = s200.homes_within_ft["1000"];
if (homes1k != null) document.getElementById("stat-homes-1k").textContent = homes1k.toLocaleString();
const protectedCount = prot500["1000"];
const pcEl = document.getElementById("protected-count");
if (pcEl && protectedCount != null) pcEl.textContent = protectedCount.toLocaleString();
}
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
/* ---------- boot ---------- */
(async function init() {
wireSetbackButtons();
wireLayerCheckboxes();
wireLayerMenu();
await loadHeadlines();
await loadComparison();
await renderAll();
setTimeout(() => loadAddresses(), 800);
})();
File diff suppressed because one or more lines are too long
+75
View File
@@ -0,0 +1,75 @@
{
"generated_at": "2026-06-27 05:14:43",
"total_homes": 56413,
"buckets_ft": [
200,
500,
1000,
1500,
2000,
2640,
5280
],
"setback_200": {
"buildable_patches": 65,
"homes_within_ft": {
"200": 0,
"500": 378,
"1000": 1797,
"1500": 3796,
"2000": 5705,
"2640": 7848,
"5280": 15582
}
},
"setback_500": {
"buildable_patches": 15,
"homes_within_ft": {
"200": 0,
"500": 0,
"1000": 270,
"1500": 1227,
"2000": 2176,
"2640": 3421,
"5280": 9574
}
},
"setback_1000": {
"buildable_patches": 2,
"homes_within_ft": {
"200": 0,
"500": 0,
"1000": 0,
"1500": 61,
"2000": 391,
"2640": 1093,
"5280": 4017
}
},
"protection": {
"to_500": {
"newly_protected_within_ft": {
"200": 0,
"500": 378,
"1000": 1527,
"1500": 2569,
"2000": 3529,
"2640": 4427,
"5280": 6008
}
},
"to_1000": {
"newly_protected_within_ft": {
"200": 0,
"500": 378,
"1000": 1797,
"1500": 3735,
"2000": 5314,
"2640": 6755,
"5280": 11565
}
}
},
"marginal_to_500": 50,
"marginal_to_1000": 63
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
{
"type": "FeatureCollection",
"name": "eligible_200ft_strict",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{ "type": "Feature", "properties": { "GISLINK": "090039 07502", "ADDRESS": "EDDIE WILLIAMS RD 2241", "OWNER": "AVIATION INVESTMENTS LTD", "ZONECLASS": "I-2", "LANDUSE": " ", "parcel_acres": 2.5896308233259004, "i2_acres": 2.5896308233259004, "max_contig_acres_200": 2.5896308233259004, "max_siteable_acres_200": 1.1175256232737436, "inscribed_diam_ft_200": 114.4, "parent_frontage_ft": 224.4, "patch_frontage_ft_200": 224.4, "buildable_acres_200": 2.5896308233259004, "buildable_pct_of_i2_200": 100.0 }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -82.322841415276145, 36.349807975042928 ], [ -82.324043945037872, 36.350869399175409 ], [ -82.324593579949195, 36.350489606573859 ], [ -82.324023971219489, 36.349982341005841 ], [ -82.32338257668664, 36.349411134557322 ], [ -82.322841415276145, 36.349807975042928 ] ] ] } },
{ "type": "Feature", "properties": { "GISLINK": "090053 06700", "ADDRESS": "PERMA R RD 114", "OWNER": "SHREDDED PRODUCTS II LLC", "ZONECLASS": "I-2", "LANDUSE": "34 - OTHER FABRICATED METAL PRODUCTS", "parcel_acres": 1.4758711066639494, "i2_acres": 1.4758711066639492, "max_contig_acres_200": 1.4758711066639492, "max_siteable_acres_200": 0.53068019014424339, "inscribed_diam_ft_200": 144.7, "parent_frontage_ft": 310.6, "patch_frontage_ft_200": 310.6, "buildable_acres_200": 1.4758711066639492, "buildable_pct_of_i2_200": 100.0 }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -82.419105974645717, 36.311328672796577 ], [ -82.419798090111556, 36.311451230132164 ], [ -82.419809471285049, 36.31073960153806 ], [ -82.419088069651337, 36.310607068171208 ], [ -82.419036916527872, 36.310775093121499 ], [ -82.419033666907339, 36.310785672425787 ], [ -82.419029524206252, 36.31079887567882 ], [ -82.419026140049837, 36.310809423942764 ], [ -82.419021821681142, 36.310822594034974 ], [ -82.419017422813198, 36.310835745509628 ], [ -82.419012930337331, 36.310848872567341 ], [ -82.419009263551885, 36.310859362858082 ], [ -82.419004603197408, 36.310872456985713 ], [ -82.418999854798628, 36.310885526861377 ], [ -82.418995012832497, 36.310898571419521 ], [ -82.418991077259349, 36.310908993325242 ], [ -82.41898607318177, 36.310922000615065 ], [ -82.418980976119954, 36.310934994328228 ], [ -82.418976832472708, 36.310945360469368 ], [ -82.418971574113954, 36.310958298902193 ], [ -82.418966227425486, 36.310971219387071 ], [ -82.418960794958636, 36.310984114785199 ], [ -82.41895526805574, 36.310996979429113 ], [ -82.418950775365104, 36.311007253163787 ], [ -82.418945087462959, 36.311020080572305 ], [ -82.418939311760568, 36.31103287832498 ], [ -82.418933443603109, 36.311045650793055 ], [ -82.418927487930347, 36.311058387301195 ], [ -82.418922659792969, 36.311068563621077 ], [ -82.418916550082386, 36.311081256787205 ], [ -82.418910345853959, 36.311093921000342 ], [ -82.41890531660313, 36.311104035449098 ], [ -82.418898959693891, 36.31111665094943 ], [ -82.41889250747991, 36.311129230259084 ], [ -82.418887285108397, 36.311139278564312 ], [ -82.418880680213292, 36.311151809161132 ], [ -82.418873987517543, 36.311164310102122 ], [ -82.418870122134805, 36.311171529064801 ], [ -82.418866317464293, 36.311178760647856 ], [ -82.418862558510526, 36.311186016130435 ], [ -82.418858861137309, 36.311193289669916 ], [ -82.418856419797422, 36.311198143001931 ], [ -82.418852809108941, 36.31120544616374 ], [ -82.418849257979218, 36.311212762813547 ], [ -82.418845755077044, 36.311220097124696 ], [ -82.418842297064103, 36.311227448998167 ], [ -82.418838913944171, 36.311234820224918 ], [ -82.418835577979735, 36.311242208179358 ], [ -82.418832294897584, 36.311249609424024 ], [ -82.418829065606772, 36.311257028494815 ], [ -82.418825896987585, 36.311264461086637 ], [ -82.418822782159822, 36.311271911504612 ], [ -82.41882024807974, 36.311278076019512 ], [ -82.419105974645717, 36.311328672796577 ] ] ] } }
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
{
"layers": [
{
"file": "eligible_1000ft_buildable.geojson",
"size_kb": 24.7
},
{
"file": "eligible_1000ft_siteable.geojson",
"size_kb": 32.1
},
{
"file": "eligible_200ft_buildable.geojson",
"size_kb": 387.0
},
{
"file": "eligible_200ft_siteable.geojson",
"size_kb": 454.0
},
{
"file": "eligible_200ft_strict.geojson",
"size_kb": 3.9
},
{
"file": "eligible_500ft_buildable.geojson",
"size_kb": 113.3
},
{
"file": "eligible_500ft_siteable.geojson",
"size_kb": 146.2
},
{
"file": "homes_protected_1000.geojson",
"size_kb": 463.2
},
{
"file": "homes_protected_500.geojson",
"size_kb": 392.1
},
{
"file": "i2_parcels.geojson",
"size_kb": 2106.3
},
{
"file": "i2_zones.geojson",
"size_kb": 658.6
},
{
"file": "marginal_parcels_1000.geojson",
"size_kb": 385.2
},
{
"file": "marginal_parcels_500.geojson",
"size_kb": 224.7
}
],
"scenarios": {},
"headlines": {
"200ft_strict": 2,
"200ft_buildable": 65,
"500ft_strict": 0,
"500ft_buildable": 15,
"1000ft_strict": 0,
"1000ft_buildable": 2
},
"generated_at": "2026-06-27 05:14:44"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+174
View File
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>200 Feet — Johnson City's Draft Data Center Setback</title>
<meta name="description" content="Hey Dave, Where can a data center go in Johnson City?" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="hero">
<div class="kicker">Johnson City, TN</div>
<h1>200&nbsp;feet<span class="period">.</span></h1>
<p class="lede">
That's the residential setback in the city's draft data-center ordinance.
It is not enough. Here's what the data shows.
</p>
<div class="stats">
<div class="stat">
<span class="num" id="stat-strict-200"></span>
<span class="lbl">parcels meet the geometric tests today, as currently configured</span>
</div>
<div class="stat">
<span class="num" id="stat-buildable-200"></span>
<span class="lbl">more become geometrically eligible after a one-lot administrative subdivision</span>
</div>
<div class="stat">
<span class="num warn" id="stat-homes-1k"></span>
<span class="lbl">homes would sit within 1,000&nbsp;ft of a possible data center site</span>
</div>
</div>
<p class="disclaimer">
Note: These are <em>zone-compatible</em> parcels — the sites where the geometry
allows a data center under the draft ordinance. Every site still requires
a Board of Zoning Appeals special-exception approval, plus noise,
vibration, environmental, and utility-capacity review before anything
is built.
</p>
</header>
<section class="mapwrap">
<div class="map-controls">
<div class="seg" id="setback-seg">
<button class="seg-btn active" data-ft="200">200 ft</button>
<button class="seg-btn" data-ft="500">500 ft</button>
<button class="seg-btn" data-ft="1000">1000 ft</button>
</div>
<details class="layer-menu">
<summary>Layers</summary>
</details>
</div>
<div class="menu-panel" id="menu-panel" hidden>
<label><input type="checkbox" id="layer-buildable" checked /> Buildable patches</label>
<label><input type="checkbox" id="layer-siteable" checked /> Building envelope (50&nbsp;ft DC yard)</label>
<label><input type="checkbox" id="layer-strict" checked /> Strict-eligible whole parcels</label>
<label><input type="checkbox" id="layer-homes-protected" /> Homes newly protected if setback raised to 500&nbsp;ft</label>
<label><input type="checkbox" id="layer-marginal" /> DC sites lost if setback raised to 500&nbsp;ft</label>
<label><input type="checkbox" id="layer-i2-zones" /> I-2 zoning footprint</label>
<label><input type="checkbox" id="layer-i2-parcels" /> All I-2 parcels (slow)</label>
</div>
<div id="map"></div>
<div class="legend">
<span><span class="sw I2"></span> I-2 zoning</span>
<span><span class="sw BUILDABLE"></span> Buildable patch</span>
<span><span class="sw SITEABLE"></span> Building envelope</span>
<span><span class="sw STRICT"></span> Strict-eligible parcel</span>
<span><span class="sw PROTECTED"></span> Home protected if setback raised to 500&nbsp;ft</span>
<span><span class="sw MARGINAL"></span> DC site lost if setback raised to 500&nbsp;ft</span>
<span><span class="sw YOU"></span> Your address</span>
</div>
</section>
<section class="checker">
<h2>How close could a data center be to your home?</h2>
<div class="addr-row">
<input id="addr-input" autocomplete="off" placeholder="e.g. 123 Main St" />
<button id="addr-go" type="button">Check</button>
</div>
<ul id="addr-suggest" class="suggest" hidden></ul>
<div id="addr-result" class="result" hidden></div>
</section>
<section class="case">
<div class="kicker">The case for 500 feet</div>
<h2>A minimum setback of 500 feet mitigates potential harm to thousands more residents while preserving viable industrial sites.</h2>
<p class="lede">
Raising the setback to <strong>500&nbsp;ft</strong> leaves
<strong id="case-remaining-500"></strong> compliant data-center sites in
Johnson City — concentrated in the city's heavy-industrial corridors.
Raising it to <strong>1,000&nbsp;ft</strong> leaves
<strong id="case-remaining-1000"></strong>. A setback increase protects thousands of residents without eliminating site viability.
</p>
<table class="case-table case-table-3col" id="case-table">
<thead>
<tr>
<th>Homes within…</th>
<th>…of a possible DC at <strong>200&nbsp;ft</strong> (proposed)</th>
<th>…at <strong>500&nbsp;ft</strong></th>
<th>…at <strong>1,000&nbsp;ft</strong></th>
</tr>
</thead>
<tbody></tbody>
</table>
<p class="case-foot">
Numbers in the 500&nbsp;ft and 1,000&nbsp;ft columns show how many homes
would <em>remain</em> within that distance of any possible data-center
site if the city tightened the setback. Lower = better.
</p>
<div class="case-actions">
<label><input type="checkbox" id="layer-homes-protected-2" /> Show the <strong id="protected-count"></strong> homes that would be newly protected at 500&nbsp;ft on the map ↑</label>
</div>
</section>
<footer class="caveats">
<h3>Disclaimer</h3>
<p>
Despite multiple requests, the City of Johnson City has declined to release internal data used to evaluate the draft ordinance or outline its methodology for determining a setback distance.
</p>
<p>
Information was compiled from Parcel, Zoning, Address, and Road Centerline GIS data as of 6/26/2026. While every attempt has been made to ensure accuracy, the data is provided "as is" and may contain errors or omissions.
</p>
<h3>Methodology</h3>
<p>
The 200&nbsp;ft setback comes from Johnson City draft ordinance
Section&nbsp;6.20.3.2.I, which forbids a data center facility within
200&nbsp;feet of any residential use or district. Measurement is
parcel-line to parcel-line.
</p>
<p>
Residential districts include all R-*,
RP-*, RO-*, A-1, and RM-3/4/5; any parcel with a residential land use
designation or containing a dwelling address point is also treated as residential.
Mixed-use districts without resdential land use do not permit single family homes and are therefore treated as non-residential, as per the text of the draft ordinance.
</p>
<p>
A parcel is counted as <em>buildable</em> if it has a contiguous compliant
patch large enough to host a 50×50&nbsp;ft building, with at least
50&nbsp;ft of public road frontage per JC subdivision regulation §4-4.1.
A parcel is <em>strict-eligible</em> if its entire boundary is at least
200&nbsp;ft from any residential line.
</p>
<p>
Every counted parcel still requires Board of Zoning Appeals
special-exception approval, plus noise, vibration, environmental, and
utility-capacity review. The geometric eligibility shown here is
necessary but not sufficient. The draft ordinance is text under
consideration, not enacted law.
</p>
<p>
This analysis is for discussion of the proposed ordinance only. <em>Do not
rely on it for real-estate, legal, or development decisions</em> — verify
any parcel-specific question with Johnson City Planning &amp; Development
Services.
</p>
<p class="footnote">
GIS data: Johnson City Planning &amp; Development Services. Distance
computed in EPSG:2274 (TN State Plane US feet).
</p>
<p class="credit">
Compiled, written, and hosted as a free public service by
<a href="https://daveforjc.com" target="_blank" rel="noopener">Dave&nbsp;Adams</a>.
</p>
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/@turf/turf@7.1.0/turf.min.js"></script>
<script src="app.js"></script>
</body>
</html>
+499
View File
@@ -0,0 +1,499 @@
:root {
--bg: #0b0d12;
--panel: #141821;
--panel-2: #1c2230;
--rule: #262d3c;
--ink: #eef1f7;
--ink-dim: #98a1b3;
--ink-dimmer: #6b7488;
--accent: #ffb84d; /* hero / UI brand color (unchanged) */
--warn: #ff6a6a; /* warn text/numbers (unchanged) */
/* Map palette -- chosen so each layer is unambiguous when overlapping. */
--i2: #3b82f6; /* I-2 zoning footprint (cool, recessive) */
--strict: #ef4444; /* strict-eligible parcels -- urgent red */
--buildable: #fbbf24; /* buildable patches -- amber */
--siteable: #f97316; /* building envelope -- deeper orange */
--protected: #22c55e; /* homes newly protected -- green = good news */
--marginal: #c084fc; /* DC site lost if raised to 500 -- purple, no overlap with red/yellow */
--you: #06b6d4; /* your address -- cyan, unmistakable single dot */
--maxw: 1080px;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid rgba(255,184,77,0.35); }
a:hover { border-bottom-color: var(--accent); }
.kicker {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--ink-dimmer);
margin-bottom: 14px;
font-weight: 500;
}
/* ---------- Hero ---------- */
.hero {
text-align: center;
padding: 80px 24px 40px;
max-width: var(--maxw);
margin: 0 auto;
}
.hero h1 {
font-size: clamp(72px, 14vw, 156px);
margin: 0;
letter-spacing: -0.05em;
font-weight: 800;
color: var(--accent);
line-height: 0.95;
}
.hero h1 .period { color: inherit; }
.hero .lede {
font-size: clamp(17px, 2.1vw, 21px);
color: var(--ink-dim);
margin: 24px auto 0;
max-width: 640px;
line-height: 1.5;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin: 48px auto 0;
max-width: 880px;
}
.stat {
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 14px;
padding: 24px 20px;
text-align: center;
}
.stat .num {
display: block;
font-size: 64px;
line-height: 1;
font-weight: 800;
color: var(--accent);
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
.stat .num.warn { color: var(--warn); }
.stat .lbl {
display: block;
font-size: 13px;
color: var(--ink-dim);
margin-top: 14px;
line-height: 1.45;
}
.disclaimer {
max-width: 720px;
margin: 28px auto 0;
padding: 14px 18px;
font-size: 13.5px;
color: var(--ink-dim);
background: var(--panel);
border: 1px solid var(--rule);
border-left: 3px solid var(--accent);
border-radius: 8px;
line-height: 1.55;
text-align: left;
}
.disclaimer em {
color: var(--ink);
font-style: normal;
font-weight: 600;
}
/* ---------- Map ---------- */
.mapwrap {
max-width: var(--maxw);
margin: 56px auto 0;
padding: 0 16px;
}
.map-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.seg {
display: inline-flex;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 999px;
padding: 3px;
overflow: hidden;
}
.seg-btn {
background: transparent;
color: var(--ink-dim);
border: 0;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border-radius: 999px;
transition: background 0.15s, color 0.15s;
font-family: inherit;
}
.seg-btn:hover { color: var(--ink); }
.seg-btn.active { background: var(--accent); color: #1a1a1a; }
/* Layers menu: the trigger button lives in .map-controls. The panel itself
is rendered INLINE in the page flow (between controls and map) — toggling
it open simply pushes the map down. No z-index, no overlays, no surprise
stacking-context fights with Leaflet. */
.layer-menu {
margin-left: auto;
}
.layer-menu > summary {
list-style: none;
cursor: pointer;
padding: 8px 14px;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: var(--ink-dim);
user-select: none;
}
.layer-menu > summary::-webkit-details-marker { display: none; }
.layer-menu > summary::after { content: " ▾"; opacity: 0.6; }
.layer-menu > summary:hover { color: var(--ink); }
.layer-menu[open] > summary { color: var(--ink); background: var(--panel-2); }
.menu-panel {
margin: 10px 0 0;
background: var(--panel-2);
border: 1px solid var(--rule);
border-radius: 10px;
padding: 4px 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.menu-panel[hidden] { display: none; }
.menu-panel label {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13.5px;
color: var(--ink);
cursor: pointer;
}
.menu-panel label:hover { background: var(--rule); border-radius: 6px; }
.menu-panel input[type=checkbox] { accent-color: var(--accent); flex-shrink: 0; }
@media (max-width: 640px) {
.menu-panel { grid-template-columns: 1fr; }
.menu-panel label { padding: 12px 14px; font-size: 14px; }
}
#map {
height: 64vh;
min-height: 520px;
border-radius: 14px;
border: 1px solid var(--rule);
background: #060810;
}
.legend {
margin-top: 12px;
font-size: 12px;
color: var(--ink-dim);
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
padding: 0 4px;
}
.legend .sw {
display: inline-block;
width: 14px; height: 10px;
margin-right: 6px;
vertical-align: middle;
border-radius: 2px;
}
.legend .sw.I2 { background: rgba(59,130,246,0.25); border: 1px solid var(--i2); }
.legend .sw.STRICT { background: rgba(239,68,68,0.30); border: 1.5px solid var(--strict); }
.legend .sw.BUILDABLE { background: rgba(251,191,36,0.40); border: 1px solid var(--buildable); }
.legend .sw.SITEABLE { background: rgba(249,115,22,0.55); border: 1px solid var(--siteable); }
.legend .sw.MARGINAL { background: transparent; border: 2px dashed var(--marginal); }
.legend .sw.PROTECTED { background: var(--protected); border-radius: 50%; width: 10px; height: 10px; border: 0; }
.legend .sw.YOU { background: var(--you); border-radius: 50%; width: 10px; height: 10px; border: 1px solid #fff; }
/* ---------- Address checker ---------- */
.checker {
max-width: 720px;
margin: 48px auto 0;
padding: 28px;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 14px;
position: relative;
}
.checker h2 {
font-size: 19px;
margin: 0 0 16px;
color: var(--ink);
font-weight: 600;
letter-spacing: -0.01em;
}
.addr-row {
display: flex;
gap: 8px;
}
.addr-row input {
flex: 1;
background: var(--bg);
color: var(--ink);
border: 1px solid var(--rule);
border-radius: 10px;
padding: 12px 14px;
font-size: 15px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.addr-row input:focus { border-color: var(--accent); }
.addr-row button {
background: var(--accent);
color: #1a1a1a;
border: 0;
border-radius: 10px;
font-weight: 700;
font-family: inherit;
padding: 0 22px;
cursor: pointer;
font-size: 14px;
}
.addr-row button:hover { filter: brightness(1.08); }
.suggest {
list-style: none;
padding: 0;
margin: 6px 0 0;
background: var(--panel-2);
border: 1px solid var(--rule);
border-radius: 10px;
max-height: 240px;
overflow-y: auto;
position: absolute;
left: 28px;
right: 100px;
z-index: 500;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.suggest li {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid var(--rule);
font-size: 14px;
}
.suggest li:last-child { border-bottom: 0; }
.suggest li:hover, .suggest li.active { background: var(--rule); }
.result {
margin-top: 18px;
padding: 16px 18px;
background: var(--bg);
border-radius: 10px;
border-left: 4px solid var(--accent);
}
.result .big {
font-size: 32px;
font-weight: 800;
color: var(--warn);
letter-spacing: -0.02em;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.result .little {
font-size: 13px;
color: var(--ink-dim);
margin-top: 8px;
line-height: 1.5;
}
.result b { color: var(--ink); }
.result .muted { color: var(--ink-dimmer); font-size: 0.75em; }
.cmp-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--rule);
}
.cmp-cell {
text-align: center;
padding: 10px 6px;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 8px;
}
.cmp-cell.active {
border-color: var(--accent);
background: var(--panel-2);
}
.cmp-cell .cmp-lbl {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--ink-dimmer);
margin-bottom: 4px;
}
.cmp-cell.active .cmp-lbl { color: var(--accent); }
.cmp-cell .cmp-val {
font-size: 17px;
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.cmp-cell .muted { color: var(--ink-dimmer); font-weight: 500; }
/* ---------- Case for 500 ft ---------- */
.case {
max-width: var(--maxw);
margin: 64px auto 0;
padding: 40px 24px;
}
.case h2 {
font-size: clamp(22px, 3vw, 30px);
font-weight: 700;
margin: 0 0 18px;
letter-spacing: -0.015em;
color: var(--ink);
}
.case .lede {
font-size: 16px;
color: var(--ink-dim);
margin: 0 0 24px;
max-width: 760px;
}
.case .lede strong { color: var(--accent); }
.case-table {
width: 100%;
border-collapse: collapse;
margin: 18px 0;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 12px;
overflow: hidden;
}
.case-table th, .case-table td {
padding: 14px 16px;
text-align: right;
border-bottom: 1px solid var(--rule);
font-variant-numeric: tabular-nums;
}
.case-table tr:last-child td { border-bottom: 0; }
.case-table th:first-child, .case-table td:first-child {
text-align: left;
color: var(--ink-dim);
font-weight: 500;
}
.case-table thead th {
color: var(--ink-dimmer);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
background: var(--panel-2);
}
.case-table tbody td:nth-child(2) { color: var(--warn); font-weight: 700; }
.case-table tbody td:nth-child(3) { color: var(--ink); }
.case-table tbody td:nth-child(4) { color: var(--protected); font-weight: 700; }
.case-table tbody td.zero { color: var(--protected); font-weight: 700; }
.case-foot {
font-size: 12.5px;
color: var(--ink-dimmer);
margin: 8px 0 12px;
line-height: 1.5;
}
.case-foot em { color: var(--ink); font-style: normal; }
.case-actions {
margin-top: 12px;
font-size: 14px;
color: var(--ink-dim);
}
.case-actions label { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
/* ---------- Caveats ---------- */
.caveats {
max-width: 760px;
margin: 72px auto 64px;
padding: 32px 24px;
border-top: 1px solid var(--rule);
font-size: 13.5px;
color: var(--ink-dim);
line-height: 1.65;
}
.caveats h3 {
margin: 0 0 14px;
font-size: 12px;
color: var(--ink-dimmer);
letter-spacing: 1.5px;
text-transform: uppercase;
font-weight: 600;
}
.caveats p { margin: 0 0 12px; }
.caveats em { color: var(--ink); font-style: normal; font-weight: 600; }
.caveats .footnote { font-size: 12px; color: var(--ink-dimmer); margin-top: 18px; }
.caveats .credit {
font-size: 13px;
color: var(--ink-dim);
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--rule);
text-align: center;
}
.caveats .credit a { color: var(--accent); font-weight: 600; }
/* ---------- Leaflet overrides ---------- */
.leaflet-container { background: #060810; font-family: inherit; }
.leaflet-popup-content-wrapper {
background: var(--panel);
color: var(--ink);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0,0,0,0.5);
}
.leaflet-popup-tip { background: var(--panel); }
.leaflet-popup-content { font-size: 13px; line-height: 1.5; margin: 14px 16px; }
.leaflet-popup-content b { color: var(--accent); }
.leaflet-control-zoom a {
background: var(--panel);
color: var(--ink);
border: 1px solid var(--rule);
}
.leaflet-control-zoom a:hover { background: var(--panel-2); }
.leaflet-control-attribution {
background: rgba(11,13,18,0.85);
color: var(--ink-dimmer);
font-size: 11px;
}
.leaflet-control-attribution a { color: var(--ink-dim); border-bottom: 0; }
@media (max-width: 640px) {
.hero { padding-top: 56px; }
.case { padding: 32px 16px; }
.case-table { font-size: 13px; }
.case-table th, .case-table td { padding: 10px 10px; }
}