/* 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: '© OSM © CARTO', }).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 `${p.ZONECLASS || "I-2"}
${p.ZONEDESC || ""}`; } function parcelPopup(p) { return `${p.ADDRESS || "(no address)"}` + `
Owner: ${p.OWNER || "—"}` + `
Zone: ${p.ZONECLASS || "—"} · Land use: ${p.LANDUSE || "—"}` + `
Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}`; } function buildablePopup(p) { const ft = state.ft; return `${p.ADDRESS || "(no address)"}` + `
Owner: ${p.OWNER || "—"}` + `
Largest contig patch: ${fmtAcres(p["max_contig_acres_" + ft])}` + `
Building envelope: ${fmtAcres(p["max_siteable_acres_" + ft])}` + `
Min building width: ${fmtFt(p["inscribed_diam_ft_" + ft])}` + `
Public road frontage: ${fmtFt(p["patch_frontage_ft_" + ft])}`; } function strictPopup(p) { const ft = state.ft; return `${p.ADDRESS || "(no address)"}` + `
Owner: ${p.OWNER || "—"}` + `
Whole parcel passes the ${ft} ft test.` + `
Parcel: ${fmtAcres(p.parcel_acres)} · In I-2: ${fmtAcres(p.i2_acres)}` + `
Min building width: ${fmtFt(p["inscribed_diam_ft_" + ft])}`; } function marginalPopup(p) { return `${p.ADDRESS || "(no address)"}` + `
Owner: ${p.OWNER || "—"}` + `
Eligible at 200 ft. Lost if setback raised to 500 ft.` + `
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 `${anonymizeAddress(p.FULLADDR)}` + `
Type: ${p.PLACETYPE || "—"}` + `
Nearest DC site @ 200 ft setback: ${fmtFt(p.dist_200_ft)}` + `
Nearest DC site @ 500 ft setback: ${fmtFt(p.dist_500_ft)}` + `
Newly outside the 1,000 ft exposure ring if setback raised to 500 ft.`; } /* ---------- 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
; 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) => `
  • ${escapeHtml(row[0])}
  • `).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(`${escapeHtml(addr)}`).addTo(map); map.setView([lat, lng], 15); // Show a loading state immediately so the user knows something is happening. addrResult.hidden = false; addrResult.innerHTML = `
    Measuring…
    `; // Guarantee the loading state is replaced even if refreshDistance throws. refreshDistance().catch(err => { console.error("refreshDistance failed", err); addrResult.innerHTML = `
    Error
    Couldn't measure distance: ${escapeHtml(err && err.message ? err.message : String(err))}.
    Open the browser console for details.
    `; }); } // 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 (outer ring) for // simple polygons, or Feature 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 `
    ${ft} ft setback
    ${v}
    `; }).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); })();