Initial commit: 200feet pipeline, docker packaging, and static site
This commit is contained in:
+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