Initial commit: 200feet pipeline, docker packaging, and static site
This commit is contained in:
@@ -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
@@ -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
@@ -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;"]
|
||||
@@ -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
|
||||
@@ -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"; }
|
||||
}
|
||||
@@ -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())
|
||||
Executable
+98
@@ -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'."
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
@@ -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: '© <a href="https://openstreetmap.org/copyright">OSM</a> © <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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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
@@ -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
@@ -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
@@ -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 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 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 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 ft</label>
|
||||
<label><input type="checkbox" id="layer-marginal" /> DC sites lost if setback raised to 500 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 ft</span>
|
||||
<span><span class="sw MARGINAL"></span> DC site lost if setback raised to 500 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 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 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 ft</strong> (proposed)</th>
|
||||
<th>…at <strong>500 ft</strong></th>
|
||||
<th>…at <strong>1,000 ft</strong></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p class="case-foot">
|
||||
Numbers in the 500 ft and 1,000 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 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 ft setback comes from Johnson City draft ordinance
|
||||
Section 6.20.3.2.I, which forbids a data center facility within
|
||||
200 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 ft building, with at least
|
||||
50 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 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 & Development
|
||||
Services.
|
||||
</p>
|
||||
<p class="footnote">
|
||||
GIS data: Johnson City Planning & 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 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
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user