← 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: '&copy; OpenStreetMap &copy; 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>