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 = `No data
`;
} else if (active.empty) {
headline = `No sites at ${state.ft} ft
A ${state.ft} ft setback eliminates every possible data center site in Johnson City.
`;
} else if (active.ftDist === 0) {
const p = active.feat.properties;
headline = `Inside a buildable site
Your address sits within an area where a data center could legally be sited under a ${state.ft} ft setback.
Patch: ${escapeHtml(p.ADDRESS || "(no address)")} · owner: ${escapeHtml(p.OWNER || "—")}.
`;
} else {
const ft = active.ftDist;
const p = active.feat.properties;
const miles = ft >= 5280 ? ` (${(ft/5280).toFixed(2)} mi)` : "";
headline = `${Math.round(ft).toLocaleString()} ft${miles}
to the nearest place a data center could legally be sited under a ${state.ft} ft setback.
Nearest site: ${escapeHtml(p.ADDRESS || "(no address)")} · owner: ${escapeHtml(p.OWNER || "—")}.
`;
}
// 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 = 'none';
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 ``;
}).join("");
const compare = `${cells}
`;
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) => `${fmt(n)} | `;
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 `| ${labelFor(b)} | ${cell(e200)}${cell(e500)}${cell(e1000)}
`;
}).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);
})();