← back to Nationalrealestate
public/index.html
404 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='10' text-anchor='middle' fill='%23c8a24b'%3EUS%3C/text%3E%3C/svg%3E">
<title>USRealEstate — National Market Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
:root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
#topbar{position:fixed;inset:0 0 auto 0;z-index:1000;display:flex;align-items:center;gap:18px;
padding:10px 18px;background:rgba(17,21,29,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
#topbar .brand{font-size:16px;font-weight:700;white-space:nowrap;}
#topbar .brand b{color:var(--gold);}
nav{display:flex;gap:14px;font-size:13px;}
nav a{color:var(--muted);text-decoration:none;} nav a:hover,nav a.active{color:var(--gold);}
#metrics{display:flex;gap:6px;margin-left:auto;flex-wrap:wrap;}
#metrics button{background:var(--bg);border:1px solid var(--line);color:var(--muted);border-radius:8px;
padding:6px 12px;font:12px -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer;}
#metrics button:hover{color:var(--fg);border-color:var(--muted);}
#metrics button.sel{color:var(--bg);background:var(--gold);border-color:var(--gold);font-weight:700;}
#map{position:absolute;inset:0;background:var(--bg);}
.leaflet-container{background:var(--bg);}
.basemap-dark{filter:brightness(.55) contrast(1.1) saturate(.4);}
#legend{position:fixed;left:14px;bottom:18px;z-index:1000;background:rgba(17,21,29,.94);
border:1px solid var(--line);border-radius:10px;padding:10px 14px;min-width:170px;}
#legend .t{font-size:12px;color:var(--gold);font-weight:700;margin-bottom:6px;}
#legend .row{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted);margin:2px 0;
font-variant-numeric:tabular-nums;}
#legend .sw{width:16px;height:11px;border-radius:2px;border:1px solid rgba(255,255,255,.15);flex:none;}
#status{position:fixed;right:14px;bottom:18px;z-index:1000;font-size:11px;color:var(--muted);
background:rgba(17,21,29,.9);border:1px solid var(--line);border-radius:8px;padding:5px 10px;}
.leaflet-tooltip.county-tip{background:var(--panel);border:1px solid var(--gold);color:var(--fg);
border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.5);font:12px -apple-system,Segoe UI,Roboto,sans-serif;}
.county-tip .nm{font-weight:700;} .county-tip .vl{color:var(--gold);font-variant-numeric:tabular-nums;}
.leaflet-control-attribution{background:rgba(17,21,29,.8)!important;color:var(--muted)!important;}
.leaflet-control-attribution a{color:var(--muted)!important;}
/* map county-filter panel (lives inside the hamburger drawer) */
#mapfilters{border-top:1px solid var(--line);padding-top:14px;margin-top:6px;}
#mapfilters .mf-h{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);font-weight:700;
display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}
#mapfilters #mf-reset{background:none;border:1px solid var(--line);color:var(--muted);border-radius:6px;
font-size:10px;padding:2px 8px;cursor:pointer;text-transform:none;letter-spacing:0;}
#mapfilters #mf-reset:hover{color:var(--gold);border-color:var(--gold);}
#mapfilters .mf-row{margin-bottom:9px;}
#mapfilters .mf-row label{display:block;font-size:11px;color:var(--muted);margin-bottom:3px;}
#mapfilters .mf-in{display:flex;gap:6px;}
#mapfilters .mf-in input{width:100%;min-width:0;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:6px;padding:5px 7px;font-size:12px;}
#mapfilters #mf-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;}
#mapfilters .mf-chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;cursor:pointer;
background:rgba(200,162,75,.12);color:var(--fg);border:1px solid var(--gold);border-radius:12px;padding:2px 8px;}
#mapfilters .mf-chip b{color:var(--gold);}
#mapfilters .mf-chip:hover{background:var(--gold);color:var(--bg);}
#mapfilters .mf-chip:hover b{color:var(--bg);}
#mapfilters #mf-count{font-size:12px;color:var(--fg);margin-top:8px;font-variant-numeric:tabular-nums;}
#mapfilters #mf-count b{color:var(--gold);}
/* on-map active-filter indicator (top-center; shown only when Map filters are active) */
#mapfbar{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:1000;align-items:center;gap:12px;
background:rgba(17,21,29,.94);border:1px solid var(--gold);border-radius:20px;padding:6px 8px 6px 16px;
font-size:12px;color:var(--muted);white-space:nowrap;backdrop-filter:blur(6px);box-shadow:0 2px 12px rgba(0,0,0,.5);}
#mapfbar b{color:var(--gold);font-variant-numeric:tabular-nums;}
#mapfbar #mapfbar-copy,#mapfbar #mapfbar-csv{background:none;color:var(--muted);border:1px solid var(--line);border-radius:14px;
font-size:11px;padding:4px 10px;cursor:pointer;}
#mapfbar #mapfbar-copy:hover,#mapfbar #mapfbar-csv:hover{color:var(--gold);border-color:var(--gold);}
#mapfbar #mapfbar-reset{background:var(--gold);color:var(--bg);border:0;border-radius:14px;
font-size:11px;font-weight:700;padding:4px 12px;cursor:pointer;}
#mapfbar #mapfbar-reset:hover{filter:brightness(1.08);}
</style>
<link rel="stylesheet" href="/nav-drawer.css">
<link rel="stylesheet" href="/geo-search.css">
</head>
<body>
<div id="topbar">
<div class="brand"><b>USRealEstate</b> — National Market Map</div>
<nav>
<a href="/" class="active">Map</a>
<a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
<a href="/compare.html">Compare</a>
<a href="/brokers.html">Brokers</a>
<a href="/sources.html">Sources</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html">Admin</a>
</nav>
<div id="metrics"></div>
</div>
<div id="map"></div>
<div id="mapfbar" style="display:none"><span id="mapfbar-txt"></span><button id="mapfbar-csv" type="button">⭳ CSV</button><button id="mapfbar-copy" type="button">🔗 link</button><button id="mapfbar-reset" type="button">reset</button></div>
<div id="legend"><div class="t">Loading…</div></div>
<div id="status">loading county data…</div>
<div id="natstrip" style="position:fixed;left:50%;transform:translateX(-50%);bottom:18px;z-index:1000;
display:none;gap:22px;background:rgba(17,21,29,.94);border:1px solid var(--line);border-radius:10px;
padding:8px 18px;font-size:12px;color:var(--muted);white-space:nowrap;"></div>
<div id="topmkts" style="position:fixed;right:14px;top:64px;z-index:1000;background:rgba(17,21,29,.94);
border:1px solid var(--line);border-radius:10px;padding:10px 12px;max-width:250px;display:none;">
<div style="font-size:12px;color:var(--gold);font-weight:700;margin-bottom:6px;">Top Markets
<a href="/markets.html" style="float:right;color:var(--muted);font-weight:400;font-size:11px;text-decoration:none;">all →</a></div>
<div id="topmkts-list"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
<script>
const METRICS = [
{ key: 'zhvi', label: 'ZHVI', fmt: usd0 },
{ key: 'median_sale_price', label: 'Median Sale Price', fmt: usd0 },
{ key: 'inventory', label: 'Inventory', fmt: int0 },
{ key: 'dom', label: 'DOM', fmt: v => int0(v) + ' days' },
{ key: 'months_of_supply', label: 'Months Supply', fmt: v => v.toFixed(1) + ' mo' },
];
// gold -> deep blue, 7 classes (high = gold)
const RAMP = ['#1b2a4a','#27406b','#3b5a83','#5f7a8e','#8f9377','#b59b5c','#c8a24b'];
const NO_DATA = '#141922';
function usd0(v){ return '$' + Math.round(v).toLocaleString(); }
function int0(v){ return Math.round(v).toLocaleString(); }
let currentMetric = localStorage.getItem('usre.metric');
if (!METRICS.some(m => m.key === currentMetric)) currentMetric = 'zhvi';
const map = L.map('map', { zoomControl: true, minZoom: 3, maxZoom: 11 }).setView([38.5, -96.5], 4);
map.zoomControl.setPosition('topright'); // clear the upper-left for the hamburger
const tiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO', subdomains: 'abcd', className: 'basemap-dark',
}).addTo(map);
let geoLayer = null;
let values = {};
let breaks = [];
let geojson = null;
function classColor(v){
if (v == null || !Number.isFinite(v)) return NO_DATA;
for (let i = breaks.length - 1; i >= 0; i--) if (v >= breaks[i]) return RAMP[i];
return RAMP[0];
}
function quantileBreaks(vals, n){
const s = [...vals].sort((a,b)=>a-b);
const out = [];
for (let i = 0; i < n; i++) out.push(s[Math.min(s.length - 1, Math.floor(i * s.length / n))]);
return out;
}
function styleFeature(f){
const pass = filterPass(f.id);
return { weight: .35, color: '#222936',
fillOpacity: pass ? .82 : .05,
fillColor: pass ? classColor(values[f.id]) : NO_DATA };
}
function metricDef(){ return METRICS.find(m => m.key === currentMetric); }
// ---- county data-point filters (left panel, inside the hamburger drawer) ----
const metricVals = {}; // metric -> { countyId: value }
const fRanges = {}; // metric -> {min,max}
METRICS.forEach(m => fRanges[m.key] = { min: null, max: null });
let filtersReady = false;
function anyFilterActive(){ return METRICS.some(m => { const r = fRanges[m.key]; return r.min != null || r.max != null; }); }
function filterPass(id){
if (!filtersReady || !anyFilterActive()) return true;
for (const m of METRICS){
const r = fRanges[m.key];
if (r.min == null && r.max == null) continue;
const v = (metricVals[m.key] || {})[id];
if (v == null || !Number.isFinite(v)) return false; // no data can't satisfy an active range
if (r.min != null && !(v >= r.min)) return false;
if (r.max != null && !(v <= r.max)) return false;
}
return true;
}
async function loadAllMetricVals(){
const res = await Promise.all(METRICS.map(m =>
fetch('/api/choropleth?metric=' + m.key).then(r => r.json()).catch(() => ({}))));
METRICS.forEach((m, i) => metricVals[m.key] = res[i].values || {});
filtersReady = true;
}
const MAPF_KEY = 'usre.mapFilters';
function saveMapFilters(){ try { localStorage.setItem(MAPF_KEY, JSON.stringify(fRanges)); } catch (e) {} }
function loadMapFilters(){
try { const s = JSON.parse(localStorage.getItem(MAPF_KEY) || 'null');
if (s) METRICS.forEach(m => { if (s[m.key]) fRanges[m.key] = { min: s[m.key].min ?? null, max: s[m.key].max ?? null }; });
} catch (e) {}
}
function readMapFiltersFromURL(){
const sp = new URLSearchParams(location.search);
let any = false;
METRICS.forEach(m => {
const mn = sp.get(m.key + '_min'), mx = sp.get(m.key + '_max');
if (mn != null && mn !== ''){ fRanges[m.key].min = +mn; any = true; }
if (mx != null && mx !== ''){ fRanges[m.key].max = +mx; any = true; }
});
return any;
}
function syncMapURL(){
const sp = new URLSearchParams();
METRICS.forEach(m => { const r = fRanges[m.key]; if (r.min != null) sp.set(m.key + '_min', r.min); if (r.max != null) sp.set(m.key + '_max', r.max); });
const qs = sp.toString();
try { history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '') + location.hash); } catch (e) {}
}
function mapChipsHTML(){
const md = k => METRICS.find(m => m.key === k);
return METRICS.filter(m => { const r = fRanges[m.key]; return r.min != null || r.max != null; }).map(m => {
const r = fRanges[m.key];
const txt = (r.min != null && r.max != null) ? `${r.min}–${r.max}` : (r.min != null ? `≥ ${r.min}` : `≤ ${r.max}`);
return `<span class="mf-chip" data-k="${m.key}">${md(m.key).label}: ${txt} <b>✕</b></span>`;
}).join('');
}
function resetMapFilters(){
METRICS.forEach(m => fRanges[m.key] = { min: null, max: null });
const wrap = document.getElementById('mapfilters');
if (wrap) wrap.querySelectorAll('input').forEach(i => i.value = '');
saveMapFilters(); applyFilters();
}
function exportMapCSV(){
if (!geojson) return;
const csvEsc = v => { if (v == null || v === '') return ''; v = String(v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
const header = ['FIPS', 'County'].concat(METRICS.map(m => m.label));
const lines = [header.map(csvEsc).join(',')];
(geojson.features || []).forEach(f => {
if (!filterPass(f.id)) return; // only the matching set
const name = (f.properties.NAME || '') + ' ' + (f.properties.LSAD || 'County');
const row = [f.id, name.trim()].concat(METRICS.map(m => { const v = (metricVals[m.key] || {})[f.id]; return v == null ? '' : v; }));
lines.push(row.map(csvEsc).join(','));
});
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'usre-map-counties.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function applyFilters(){
if (geoLayer) geoLayer.setStyle(styleFeature);
syncMapURL(); // keep the query string in sync so filtered map views are shareable
const chips = document.getElementById('mf-chips');
if (chips) chips.innerHTML = mapChipsHTML();
const el = document.getElementById('mf-count');
const bar = document.getElementById('mapfbar');
if (!anyFilterActive() || !filtersReady){
if (el) el.textContent = '';
if (bar) bar.style.display = 'none';
return;
}
let n = 0, tot = 0;
(geojson && geojson.features || []).forEach(f => { tot++; if (filterPass(f.id)) n++; });
if (el) el.innerHTML = `<b>${n.toLocaleString()}</b> of ${tot.toLocaleString()} counties match`;
// on-map indicator so the filtered state is legible without opening the drawer
if (bar){ document.getElementById('mapfbar-txt').innerHTML = `<b>${n.toLocaleString()}</b> counties match · filters on`; bar.style.display = 'flex'; }
}
function buildMapFilters(){
const host = document.getElementById('navDrawerBody');
if (!host || document.getElementById('mapfilters')) return;
loadMapFilters(); readMapFiltersFromURL(); // localStorage first, then URL query wins (shareable links)
const wrap = document.createElement('div'); wrap.id = 'mapfilters';
wrap.innerHTML = `<div class="mf-h">Filter counties <button id="mf-reset" type="button">reset</button></div>` +
METRICS.map(m => { const r = fRanges[m.key];
return `<div class="mf-row" data-k="${m.key}"><label>${m.label}</label>` +
`<div class="mf-in"><input type="number" class="mf-min" placeholder="min"${r.min != null ? ` value="${r.min}"` : ''}>` +
`<input type="number" class="mf-max" placeholder="max"${r.max != null ? ` value="${r.max}"` : ''}></div></div>`;
}).join('') +
`<div id="mf-chips"></div><div id="mf-count"></div>`;
host.appendChild(wrap);
wrap.querySelectorAll('.mf-row').forEach(row => {
const k = row.dataset.k;
row.querySelector('.mf-min').addEventListener('input', e => { fRanges[k].min = e.target.value ? +e.target.value : null; saveMapFilters(); applyFilters(); });
row.querySelector('.mf-max').addEventListener('input', e => { fRanges[k].max = e.target.value ? +e.target.value : null; saveMapFilters(); applyFilters(); });
});
wrap.querySelector('#mf-reset').addEventListener('click', resetMapFilters);
const bar = document.getElementById('mapfbar');
if (bar){
bar.querySelector('#mapfbar-reset').addEventListener('click', resetMapFilters);
const cpy = bar.querySelector('#mapfbar-copy');
if (cpy) cpy.addEventListener('click', () => {
const t = cpy.textContent;
if (navigator.clipboard) navigator.clipboard.writeText(location.href);
cpy.textContent = '✓ copied'; setTimeout(() => cpy.textContent = t, 1200);
});
const csv = bar.querySelector('#mapfbar-csv');
if (csv) csv.addEventListener('click', exportMapCSV);
}
// active-filter chips: click ✕ to clear that metric's range
wrap.querySelector('#mf-chips').addEventListener('click', e => {
const chip = e.target.closest('.mf-chip'); if (!chip) return;
const k = chip.dataset.k; fRanges[k] = { min: null, max: null };
const row = wrap.querySelector(`.mf-row[data-k="${k}"]`);
if (row){ row.querySelector('.mf-min').value = ''; row.querySelector('.mf-max').value = ''; }
saveMapFilters(); applyFilters();
});
applyFilters(); // render restored chips/inputs immediately (count fills once metric values load)
}
function renderLegend(count){
const md = metricDef();
const el = document.getElementById('legend');
if (!breaks.length){ el.innerHTML = `<div class="t">${md.label}</div><div class="row">no data yet</div>`; return; }
let html = `<div class="t">${md.label}</div>`;
for (let i = RAMP.length - 1; i >= 0; i--){
const lo = md.fmt(breaks[i]);
const hi = i < RAMP.length - 1 ? md.fmt(breaks[i + 1]) : null;
html += `<div class="row"><span class="sw" style="background:${RAMP[i]}"></span>${hi ? lo + ' – ' + hi : lo + '+'}</div>`;
}
html += `<div class="row"><span class="sw" style="background:${NO_DATA}"></span>no data</div>`;
el.innerHTML = html;
document.getElementById('status').textContent = `${count.toLocaleString()} counties · latest period`;
}
async function loadMetric(key){
currentMetric = key;
localStorage.setItem('usre.metric', key);
renderButtons();
document.getElementById('status').textContent = 'loading ' + key + '…';
const d = await fetch('/api/choropleth?metric=' + encodeURIComponent(key)).then(r => r.json());
values = d.values || {};
const vals = Object.values(values).filter(Number.isFinite);
breaks = vals.length ? quantileBreaks(vals, RAMP.length) : [];
if (geoLayer) geoLayer.setStyle(styleFeature);
renderLegend(vals.length);
}
function renderButtons(){
const el = document.getElementById('metrics');
el.innerHTML = '';
for (const m of METRICS){
const b = document.createElement('button');
b.textContent = m.label;
b.className = m.key === currentMetric ? 'sel' : '';
b.onclick = () => loadMetric(m.key);
el.appendChild(b);
}
}
function onEachFeature(f, layer){
layer.on('mouseover', () => {
const md = metricDef();
const v = values[f.id];
const nm = `${f.properties.NAME} ${f.properties.LSAD || 'County'}`;
layer.setStyle({ weight: 1.6, color: '#c8a24b' });
layer.bringToFront();
layer.bindTooltip(
`<div class="nm">${nm}</div><div class="vl">${v != null && Number.isFinite(v) ? md.fmt(v) : 'no data'}</div>`,
{ className: 'county-tip', sticky: true },
).openTooltip();
});
layer.on('mouseout', () => { geoLayer.resetStyle(layer); layer.closeTooltip(); });
layer.on('click', () => { location.href = `/market.html?key=county:${f.id}`; });
}
async function loadNationalStrip(){
try {
const [z, y, d] = await Promise.all(['zhvi','zhvi_yoy','dom'].map(m =>
fetch('/api/choropleth?metric=' + m).then(r => r.json())));
const med = obj => { const v = Object.values(obj.values || {}).filter(Number.isFinite).sort((a,b)=>a-b);
return v.length ? v[Math.floor(v.length/2)] : null; };
const yv = Object.values(y.values || {}).filter(Number.isFinite);
const rising = yv.length ? Math.round(100 * yv.filter(v => v > 0).length / yv.length) : null;
const parts = [];
const mz = med(z); if (mz != null) parts.push(`median county home value <b style="color:var(--gold)">$${Math.round(mz).toLocaleString()}</b>`);
if (rising != null) parts.push(`<b style="color:${rising >= 50 ? '#3fb950' : '#f85149'}">${rising}%</b> of counties rising YoY`);
const md = med(d); if (md != null) parts.push(`median DOM <b style="color:var(--gold)">${Math.round(md)}d</b>`);
if (parts.length){ const el = document.getElementById('natstrip');
el.innerHTML = parts.map(p => `<span>${p}</span>`).join(''); el.style.display = 'flex'; }
} catch {}
}
async function loadTopMarkets(){
try {
const d = await fetch('/api/markets?type=county').then(r => r.json());
const top = (d.rows || []).filter(r => r.rank).sort((a, b) => a.rank - b.rank).slice(0, 8);
if (!top.length) return;
document.getElementById('topmkts-list').innerHTML = top.map(r => `
<a href="/market.html?key=${r.key}" style="display:flex;gap:8px;align-items:baseline;
text-decoration:none;color:var(--fg);font-size:12px;padding:2px 0;">
<span style="color:var(--muted);font-variant-numeric:tabular-nums;width:16px;">${r.rank}</span>
<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${r.name}, ${r.state}</span>
<span style="color:var(--gold);font-weight:700;font-variant-numeric:tabular-nums;">${r.opportunity_score.toFixed(1)}</span>
</a>`).join('');
document.getElementById('topmkts').style.display = 'block';
} catch {}
}
(async function init(){
renderButtons();
const [geo] = await Promise.all([
fetch('/data/counties.geojson').then(r => r.json()),
loadMetric(currentMetric),
]);
geojson = geo;
geoLayer = L.geoJSON(geo, { style: styleFeature, onEachFeature }).addTo(map);
buildMapFilters();
loadTopMarkets();
loadNationalStrip();
loadAllMetricVals().then(applyFilters); // enable county filtering once all metric values are in
})();
</script>
</body>
</html>