Files

458 lines
20 KiB
JavaScript

/* 200feet.net — Johnson City data-center setback explorer.
Pure static. Renders eligible parcels on a Leaflet map and lets users
measure how close a data center could legally be to any JC address. */
// Bumped whenever the data payload schema changes -- forces browsers to
// re-fetch JSON/GeoJSON instead of using stale cached copies.
const DATA_V = "8";
const v = (url) => url + (url.includes("?") ? "&" : "?") + "v=" + DATA_V;
// Palette mirrors the CSS :root variables. Keep in sync with style.css.
const C = {
i2: "#3b82f6", // I-2 zoning
strict: "#ef4444", // strict-eligible parcels (urgent red)
buildable: "#fbbf24", // buildable patches (amber)
siteable: "#f97316", // building envelope (deeper orange)
protected: "#22c55e", // homes newly protected (green)
marginal: "#c084fc", // DC sites lost (purple)
you: "#06b6d4", // your address (cyan)
};
const STYLES = {
i2zone: { color: C.i2, weight: 1, fillColor: C.i2, fillOpacity: 0.15 },
i2parcel: { color: "#98a1b3", weight: 0.5, fillOpacity: 0 },
strict: { color: C.strict, weight: 2.5, fillColor: C.strict, fillOpacity: 0.35 },
buildable: { color: C.buildable, weight: 1, fillColor: C.buildable, fillOpacity: 0.45 },
siteable: { color: C.siteable, weight: 1, fillColor: C.siteable, fillOpacity: 0.55 },
marginal: { color: C.marginal, weight: 3, fillOpacity: 0, dashArray: "8 6" },
};
const POINT_STYLES = {
homesProtected: { radius: 4, fillColor: C.protected, color: "#15803d", weight: 1, fillOpacity: 0.80 },
};
const layerSpec = [
{ id: "layer-i2-zones", url: "data/i2_zones.geojson", style: STYLES.i2zone, popup: i2ZonePopup },
{ id: "layer-i2-parcels", url: "data/i2_parcels.geojson", style: STYLES.i2parcel, popup: parcelPopup },
{ id: "layer-buildable", url: "data/eligible_{ft}ft_buildable.geojson", style: STYLES.buildable, popup: buildablePopup, perFt: true },
{ id: "layer-siteable", url: "data/eligible_{ft}ft_siteable.geojson", style: STYLES.siteable, popup: buildablePopup, perFt: true },
{ id: "layer-strict", url: "data/eligible_{ft}ft_strict.geojson", style: STYLES.strict, popup: strictPopup, perFt: true },
{ id: "layer-marginal", url: "data/marginal_parcels_500.geojson", style: STYLES.marginal, popup: marginalPopup, alwaysTop: true },
{ id: "layer-homes-protected", url: "data/homes_protected_500.geojson", pointStyle: POINT_STYLES.homesProtected, popup: protectedHomePopup, alwaysTop: true },
];
const map = L.map("map", { center: [36.3134, -82.3535], zoom: 12 });
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const state = {
ft: 200,
layers: {},
geojsonCache: {},
addresses: null,
addressesLoading: null,
userMarker: null,
nearestHighlight: null,
};
/* ---------- popup formatters ---------- */
function fmtAcres(a) { return (a == null) ? "—" : a.toFixed(2) + " ac"; }
function fmtFt(n) { return (n == null) ? "—" : Math.round(n).toLocaleString() + " ft"; }
function i2ZonePopup(p) { return `<b>${p.ZONECLASS || "I-2"}</b><br>${p.ZONEDESC || ""}`; }
function parcelPopup(p) {
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br>Zone: ${p.ZONECLASS || "—"} · Land use: ${p.LANDUSE || "—"}` +
`<br>Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}`;
}
function buildablePopup(p) {
const ft = state.ft;
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br>Largest contig patch: <b>${fmtAcres(p["max_contig_acres_" + ft])}</b>` +
`<br>Building envelope: ${fmtAcres(p["max_siteable_acres_" + ft])}` +
`<br>Min building width: <b>${fmtFt(p["inscribed_diam_ft_" + ft])}</b>` +
`<br>Public road frontage: <b>${fmtFt(p["patch_frontage_ft_" + ft])}</b>`;
}
function strictPopup(p) {
const ft = state.ft;
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br>Whole parcel passes the ${ft} ft test.` +
`<br>Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}` +
`<br>Min building width: <b>${fmtFt(p["inscribed_diam_ft_" + ft])}</b>`;
}
function marginalPopup(p) {
return `<b>${p.ADDRESS || "(no address)"}</b>` +
`<br>Owner: ${p.OWNER || "—"}` +
`<br><span style="color:${C.marginal}">Eligible at 200 ft. Lost if setback raised to 500 ft.</span>` +
`<br>Buildable @ 200 ft: ${fmtAcres(p.max_contig_acres_200)}`;
}
// Anonymize a house-level address to the 100-block to avoid individually
// fingerprinting a single home in the popup. "414 BRICE LN" -> "400 block of
// BRICE LN". Leaves addresses without a leading numeric house number alone.
function anonymizeAddress(addr) {
if (!addr) return "(no address)";
const m = String(addr).trim().match(/^(\d+)\s+(.+)$/);
if (!m) return String(addr);
const num = parseInt(m[1], 10);
if (!Number.isFinite(num) || num <= 0) return String(addr);
const block = Math.floor(num / 100) * 100;
return `${block} block of ${m[2]}`;
}
function protectedHomePopup(p) {
return `<b>${anonymizeAddress(p.FULLADDR)}</b>` +
`<br>Type: ${p.PLACETYPE || "—"}` +
`<br>Nearest DC site @ 200 ft setback: <b>${fmtFt(p.dist_200_ft)}</b>` +
`<br>Nearest DC site @ 500 ft setback: <b>${fmtFt(p.dist_500_ft)}</b>` +
`<br><span style="color:${C.protected}">Newly outside the 1,000 ft exposure ring if setback raised to 500 ft.</span>`;
}
/* ---------- data loading ---------- */
async function getGeoJSON(url) {
if (!state.geojsonCache[url]) {
const p = fetch(v(url)).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status} ${url}`)));
// Don't keep a rejected promise in cache -- retry on next call.
p.catch(() => { delete state.geojsonCache[url]; });
state.geojsonCache[url] = p;
}
return state.geojsonCache[url];
}
async function renderLayer(spec) {
if (state.layers[spec.id]) { map.removeLayer(state.layers[spec.id]); delete state.layers[spec.id]; }
const checkbox = document.getElementById(spec.id);
if (!checkbox || !checkbox.checked) return;
const url = spec.url.replace("{ft}", state.ft);
let gj;
try { gj = await getGeoJSON(url); }
catch (e) { console.warn("missing", url); return; }
const opts = {
onEachFeature: (feature, lyr) => lyr.bindPopup(spec.popup(feature.properties)),
};
if (spec.style) opts.style = spec.style;
if (spec.pointStyle) {
opts.pointToLayer = (feature, latlng) => L.circleMarker(latlng, spec.pointStyle);
}
const layer = L.geoJSON(gj, opts);
layer.addTo(map);
if (spec.alwaysTop) layer.bringToFront();
state.layers[spec.id] = layer;
}
async function renderAll() { for (const spec of layerSpec) await renderLayer(spec); }
/* ---------- controls ---------- */
function wireSetbackButtons() {
document.querySelectorAll(".seg-btn").forEach(btn => {
btn.addEventListener("click", async () => {
document.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.ft = parseInt(btn.dataset.ft, 10);
for (const spec of layerSpec) if (spec.perFt) await renderLayer(spec);
// Keep marginal layer on top after re-render
const mLayer = state.layers["layer-marginal"];
if (mLayer) mLayer.bringToFront();
if (state.userMarker) refreshDistance();
});
});
}
function wireLayerCheckboxes() {
layerSpec.forEach(spec => {
const cb = document.getElementById(spec.id);
if (cb) cb.addEventListener("change", () => renderLayer(spec));
});
// Mirror the case-section homes-protected toggle to the layer-menu one.
pairMirror("layer-homes-protected", "layer-homes-protected-2");
}
function pairMirror(primaryId, mirrorId) {
const primary = document.getElementById(primaryId);
const mirror = document.getElementById(mirrorId);
if (!primary || !mirror) return;
mirror.checked = primary.checked;
mirror.addEventListener("change", () => {
if (primary.checked !== mirror.checked) {
primary.checked = mirror.checked;
primary.dispatchEvent(new Event("change"));
}
});
primary.addEventListener("change", () => {
if (mirror.checked !== primary.checked) mirror.checked = primary.checked;
});
}
function wireLayerMenu() {
// The menu trigger lives inside <details>; the panel itself lives outside
// (as a sibling of the map) so opening the menu pushes the map down rather
// than overlapping it. We show/hide the panel based on the details state.
const menu = document.querySelector(".layer-menu");
const panel = document.getElementById("menu-panel");
if (!menu || !panel) return;
const sync = () => { panel.hidden = !menu.open; };
menu.addEventListener("toggle", sync);
sync();
}
/* ---------- address checker ---------- */
async function loadAddresses() {
if (state.addresses) return state.addresses;
if (state.addressesLoading) return state.addressesLoading;
state.addressesLoading = fetch(v("data/addresses.json")).then(r => r.json()).then(arr => {
state.addresses = arr.map(([a, lat, lng]) => [a, lat, lng, a.toLowerCase()]);
return state.addresses;
});
return state.addressesLoading;
}
const debounce = (fn, ms) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; };
const addrInput = document.getElementById("addr-input");
const addrSuggest = document.getElementById("addr-suggest");
const addrGo = document.getElementById("addr-go");
const addrResult = document.getElementById("addr-result");
let suggestActive = -1;
let suggestItems = [];
const onType = debounce(async (val) => {
if (!val || val.length < 2) { addrSuggest.hidden = true; return; }
const arr = await loadAddresses();
const q = val.toLowerCase();
const tokens = q.split(/\s+/).filter(Boolean);
const out = [];
for (let i = 0; i < arr.length && out.length < 12; i++) {
const lc = arr[i][3];
let ok = true;
for (const t of tokens) if (lc.indexOf(t) < 0) { ok = false; break; }
if (ok) out.push(arr[i]);
}
suggestItems = out;
if (!out.length) { addrSuggest.hidden = true; return; }
addrSuggest.innerHTML = out.map((row, i) => `<li data-i="${i}">${escapeHtml(row[0])}</li>`).join("");
addrSuggest.hidden = false;
suggestActive = -1;
}, 80);
addrInput.addEventListener("input", e => onType(e.target.value));
addrInput.addEventListener("keydown", e => {
if (addrSuggest.hidden) return;
if (e.key === "ArrowDown") { e.preventDefault(); moveSuggest(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); moveSuggest(-1); }
else if (e.key === "Enter") { e.preventDefault(); pickSuggest(suggestActive >= 0 ? suggestActive : 0); }
else if (e.key === "Escape") addrSuggest.hidden = true;
});
addrSuggest.addEventListener("click", e => {
const li = e.target.closest("li");
if (li) pickSuggest(parseInt(li.dataset.i, 10));
});
addrGo.addEventListener("click", () => { if (suggestItems[0]) pickSuggest(0); });
document.addEventListener("click", e => { if (!e.target.closest(".checker")) addrSuggest.hidden = true; });
function moveSuggest(delta) {
suggestActive = (suggestActive + delta + suggestItems.length) % suggestItems.length;
[...addrSuggest.children].forEach((li, i) => li.classList.toggle("active", i === suggestActive));
}
function pickSuggest(i) {
const row = suggestItems[i];
if (!row) return;
addrInput.value = row[0];
addrSuggest.hidden = true;
placeUser(row[0], row[1], row[2]);
}
function placeUser(addr, lat, lng) {
if (state.userMarker) map.removeLayer(state.userMarker);
state.userMarker = L.circleMarker([lat, lng], {
color: "#ffffff", weight: 2, fillColor: C.you, fillOpacity: 1, radius: 8,
}).bindPopup(`<b>${escapeHtml(addr)}</b>`).addTo(map);
map.setView([lat, lng], 15);
// Show a loading state immediately so the user knows something is happening.
addrResult.hidden = false;
addrResult.innerHTML = `<div class="big" style="color:var(--ink-dim)">Measuring…</div>`;
// Guarantee the loading state is replaced even if refreshDistance throws.
refreshDistance().catch(err => {
console.error("refreshDistance failed", err);
addrResult.innerHTML = `<div class="big">Error</div>
<div class="little">Couldn't measure distance: ${escapeHtml(err && err.message ? err.message : String(err))}.<br>
Open the browser console for details.</div>`;
});
}
// Distance (km) from a point to the nearest edge of a polygon feature.
// Handles Polygon, MultiPolygon, and GeometryCollection. Returns 0 if the
// point is inside the polygon.
function distancePointToFeatureKm(point, feature) {
if (!feature || !feature.geometry) return Infinity;
const gtype = feature.geometry.type;
if (gtype === "GeometryCollection") {
let min = Infinity;
for (const g of feature.geometry.geometries) {
const d = distancePointToFeatureKm(point, { type: "Feature", geometry: g, properties: feature.properties });
if (d < min) min = d;
}
return min;
}
if (gtype === "MultiPolygon") {
let min = Infinity;
for (const polyCoords of feature.geometry.coordinates) {
const polyFeat = turf.polygon(polyCoords, feature.properties);
const d = distancePointToFeatureKm(point, polyFeat);
if (d < min) min = d;
}
return min;
}
if (gtype !== "Polygon") return Infinity;
try {
if (turf.booleanPointInPolygon(point, feature)) return 0;
// polygonToLine on a Polygon returns Feature<LineString> (outer ring) for
// simple polygons, or Feature<MultiLineString> if there are holes. Both
// are valid inputs to pointToLineDistance.
const line = turf.polygonToLine(feature);
// turf 7 polygonToLine sometimes returns a FeatureCollection -- normalize.
if (line.type === "FeatureCollection") {
let min = Infinity;
for (const f of line.features) {
const d = turf.pointToLineDistance(point, f, { units: "kilometers" });
if (d < min) min = d;
}
return min;
}
return turf.pointToLineDistance(point, line, { units: "kilometers" });
} catch (e) {
console.warn("distance fallback for feature", e);
// Manual fallback: nearest vertex.
let min = Infinity;
turf.coordEach(feature, (coord) => {
const d = turf.distance(point, turf.point(coord), { units: "kilometers" });
if (d < min) min = d;
});
return min;
}
}
async function refreshDistance() {
const m = state.userMarker;
if (!m) return;
const userPoint = turf.point([m.getLatLng().lng, m.getLatLng().lat]);
// Compute nearest at all three setbacks so the user gets a comparison.
const setbacks = [200, 500, 1000];
const results = {};
for (const ft of setbacks) {
let gj;
try { gj = await getGeoJSON(`data/eligible_${ft}ft_buildable.geojson`); }
catch (e) { results[ft] = { empty: true }; continue; }
if (!gj || !gj.features || !gj.features.length) { results[ft] = { empty: true }; continue; }
let best = null;
for (const feat of gj.features) {
let d;
try { d = distancePointToFeatureKm(userPoint, feat); }
catch (err) { console.warn("skipped feature", err); continue; }
if (best === null || d < best.d) best = { d, feat };
}
results[ft] = best ? { ftDist: best.d * 3280.84, feat: best.feat } : { empty: true };
}
const active = results[state.ft];
addrResult.hidden = false;
// Headline section -- nearest at the currently selected setback
let headline;
if (!active) {
headline = `<div class="big" style="color:var(--ink-dim)">No data</div>`;
} else if (active.empty) {
headline = `<div class="big">No sites at ${state.ft} ft</div>
<div class="little">A ${state.ft} ft setback eliminates every possible data center site in Johnson City.</div>`;
} else if (active.ftDist === 0) {
const p = active.feat.properties;
headline = `<div class="big">Inside a buildable site</div>
<div class="little">Your address sits within an area where a data center could legally be sited under a ${state.ft} ft setback.<br>
Patch: <b>${escapeHtml(p.ADDRESS || "(no address)")}</b> · owner: <b>${escapeHtml(p.OWNER || "—")}</b>.</div>`;
} else {
const ft = active.ftDist;
const p = active.feat.properties;
const miles = ft >= 5280 ? ` <span class="muted">(${(ft/5280).toFixed(2)} mi)</span>` : "";
headline = `<div class="big">${Math.round(ft).toLocaleString()} ft${miles}</div>
<div class="little">to the nearest place a data center could legally be sited under a <b>${state.ft} ft</b> setback.<br>
Nearest site: <b>${escapeHtml(p.ADDRESS || "(no address)")}</b> · owner: <b>${escapeHtml(p.OWNER || "—")}</b>.</div>`;
}
// Comparison row: nearest at each setback so the user sees the change.
const cells = setbacks.map(ft => {
const r = results[ft];
let v;
if (!r) v = "—";
else if (r.empty) v = '<span class="muted">none</span>';
else if (r.ftDist === 0) v = "inside";
else v = `${Math.round(r.ftDist).toLocaleString()} ft`;
const cls = ft === state.ft ? "cmp-cell active" : "cmp-cell";
return `<div class="${cls}"><div class="cmp-lbl">${ft} ft setback</div><div class="cmp-val">${v}</div></div>`;
}).join("");
const compare = `<div class="cmp-row">${cells}</div>`;
addrResult.innerHTML = headline + compare;
// Highlight the nearest site for the currently selected setback.
if (state.nearestHighlight) { map.removeLayer(state.nearestHighlight); state.nearestHighlight = null; }
if (active && !active.empty && active.feat) {
state.nearestHighlight = L.geoJSON(active.feat, {
style: { color: C.you, weight: 4, fillColor: C.you, fillOpacity: 0.20 },
}).addTo(map);
}
}
/* ---------- headlines / comparison ---------- */
async function loadHeadlines() {
const m = await (await fetch(v("data/manifest.json"))).json();
const h = m.headlines || {};
document.getElementById("stat-strict-200").textContent = h["200ft_strict"] ?? "—";
document.getElementById("stat-buildable-200").textContent = h["200ft_buildable"] ?? "—";
}
async function loadComparison() {
let c;
try { c = await (await fetch(v("data/comparison.json"))).json(); }
catch (e) { console.warn("no comparison.json"); return; }
const s200 = c.setback_200, s500 = c.setback_500, s1000 = c.setback_1000;
const prot500 = c.protection.to_500.newly_protected_within_ft;
// Drop the 200ft bucket (all zeros by construction at every setback).
const buckets = c.buckets_ft.filter(b => b >= 500);
const fmt = n => n.toLocaleString();
const cell = (n) => `<td${n === 0 ? ' class="zero"' : ""}>${fmt(n)}</td>`;
const labelFor = b => b >= 5280 ? "1 mile"
: b >= 2640 ? "½ mile"
: `${b.toLocaleString()} ft`;
const tbody = document.querySelector("#case-table tbody");
tbody.innerHTML = buckets.map(b => {
const e200 = s200.homes_within_ft[b] ?? 0;
const e500 = s500.homes_within_ft[b] ?? 0;
const e1000 = s1000.homes_within_ft[b] ?? 0;
return `<tr><td>${labelFor(b)}</td>${cell(e200)}${cell(e500)}${cell(e1000)}</tr>`;
}).join("");
document.getElementById("case-remaining-500").textContent = s500.buildable_patches;
document.getElementById("case-remaining-1000").textContent = s1000.buildable_patches;
const homes1k = s200.homes_within_ft["1000"];
if (homes1k != null) document.getElementById("stat-homes-1k").textContent = homes1k.toLocaleString();
const protectedCount = prot500["1000"];
const pcEl = document.getElementById("protected-count");
if (pcEl && protectedCount != null) pcEl.textContent = protectedCount.toLocaleString();
}
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
/* ---------- boot ---------- */
(async function init() {
wireSetbackButtons();
wireLayerCheckboxes();
wireLayerMenu();
await loadHeadlines();
await loadComparison();
await renderAll();
setTimeout(() => loadAddresses(), 800);
})();