← back to Nationalrealestate

public/compare.html

203 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 — Compare Markets</title>
<link rel="stylesheet" href="/vendor/uPlot.min.css">
<style>
  :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;}
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
  #topbar{position:sticky;top:0;z-index:10;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;} #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);}
  main{max-width:1250px;margin:0 auto;padding:20px 18px;}
  #pickrow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:16px;}
  #sugg{position:relative;flex:1;min-width:240px;}
  #q{width:100%;background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:9px;padding:9px 12px;font-size:13px;}
  #q:focus{outline:none;border-color:var(--gold);}
  #sugglist{position:absolute;top:100%;left:0;right:0;z-index:20;background:var(--panel);border:1px solid var(--line);
    border-radius:0 0 10px 10px;max-height:280px;overflow:auto;display:none;}
  #sugglist div{padding:7px 12px;cursor:pointer;font-size:13px;} #sugglist div:hover{background:var(--bg);color:var(--gold);}
  .sel-chip{display:flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--gold);
    border-radius:999px;padding:5px 12px;font-size:12px;}
  .sel-chip b{color:var(--gold);} .sel-chip button{background:none;border:0;color:var(--muted);cursor:pointer;font-size:14px;padding:0;}
  table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;}
  th,td{padding:8px 14px;border-bottom:1px solid var(--line);text-align:right;font-variant-numeric:tabular-nums;font-size:13px;}
  th{background:var(--bg);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px;}
  td:first-child,th:first-child{text-align:left;color:var(--muted);}
  td.best{color:var(--gold);font-weight:700;}
  .charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:14px;margin-top:18px;}
  .chartbox{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;}
  .chartbox h3{margin:0 0 6px;font-size:13px;color:var(--gold);}
  .lgd{display:flex;gap:12px;font-size:11px;color:var(--muted);margin-bottom:4px;flex-wrap:wrap;}
  .lgd span::before{content:'▬ ';font-size:13px;}
  .empty{color:var(--muted);padding:50px;text-align:center;}
</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> — Compare Markets</div>
  <nav>
    <a href="/">Map</a>
    <a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
    <a href="/compare.html" class="active">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>
<main>
  <div id="pickrow">
    <div id="sugg"><input id="q" placeholder="Add a county or metro to compare (up to 4)… e.g. Austin, Sangamon, Los Angeles" autocomplete="off"><div id="sugglist"></div></div>
    <span id="chips"></span>
  </div>
  <div id="content"><div class="empty">Pick 2–4 markets above to compare them side-by-side.</div></div>
</main>
<script src="/vendor/uPlot.iife.min.js"></script>
<script>
const COLORS = ['#c8a24b','#5b9bd5','#3fb950','#e6550d'];
const LS_KEY = 'usreCompare.keys';
let regions = [];
let selected = [];
try { selected = JSON.parse(localStorage.getItem(LS_KEY) || '[]').slice(0, 4); } catch {}

const ROWS = [
  ['Opportunity score', 'score', v => v == null ? '—' : v.toFixed(1), 'max'],
  ['ZHVI (home value)', 'zhvi', usd, 'min'],
  ['ZHVI YoY', 'zhvi_yoy', pct, 'max'],
  ['Median sale price', 'median_sale_price', usd, 'min'],
  ['Median HH income', 'median_hh_income', usd, 'max'],
  ['Affordability (price/income)', 'affordability', v => v == null ? '—' : v.toFixed(1) + '×', 'min'],
  ['Rent yield', 'rent_yield', pct, 'max'],
  ['FMR 2BR', 'fmr_2br', usd, null],
  ['ZORI rent', 'zori', usd, null],
  ['Days on market', 'dom', v => v == null ? '—' : Math.round(v) + 'd', 'min'],
  ['Months of supply', 'months_of_supply', v => v == null ? '—' : v.toFixed(1), 'min'],
  ['Sale-to-list', 'sale_to_list', pct, 'max'],
  ['Population', 'population', v => v == null ? '—' : (+v).toLocaleString(), null],
];
function usd(v){ return v == null ? '—' : '$' + Math.round(v).toLocaleString(); }
function pct(v){ return v == null ? '—' : (v * 100).toFixed(1) + '%'; }

async function loadRegions(){
  const [c, m] = await Promise.all([
    fetch('/api/regions?type=county').then(r => r.json()),
    fetch('/api/regions?type=metro').then(r => r.json()),
  ]);
  regions = [...c.regions, ...m.regions];
}

const q = document.getElementById('q'), sl = document.getElementById('sugglist');
q.addEventListener('input', () => {
  const s = q.value.trim().toLowerCase();
  if (s.length < 2){ sl.style.display = 'none'; return; }
  const hits = regions.filter(r => r.name.toLowerCase().includes(s) && !selected.includes(r.canonical_key)).slice(0, 12);
  sl.innerHTML = hits.map(r =>
    `<div data-key="${r.canonical_key}">${r.name}${r.state_code ? ', ' + r.state_code : ''} <span style="color:var(--muted)">· ${r.region_type}</span></div>`).join('');
  sl.style.display = hits.length ? 'block' : 'none';
  [...sl.children].forEach(div => div.onclick = () => {
    if (selected.length >= 4) return;
    selected.push(div.dataset.key);
    q.value = ''; sl.style.display = 'none';
    persist(); render();
  });
});

function persist(){ localStorage.setItem(LS_KEY, JSON.stringify(selected)); }

function renderChips(datas){
  document.getElementById('chips').innerHTML = selected.map((k, i) => {
    const nm = datas?.[i]?.region ? `${datas[i].region.name}${datas[i].region.state_code ? ', ' + datas[i].region.state_code : ''}` : k;
    return `<span class="sel-chip" style="border-color:${COLORS[i]}"><b style="color:${COLORS[i]}">●</b> ${nm}
      <button data-i="${i}">✕</button></span>`;
  }).join('');
  [...document.querySelectorAll('.sel-chip button')].forEach(b => b.onclick = () => {
    selected.splice(+b.dataset.i, 1); persist(); render();
  });
}

function latestOf(d, metric){
  const v = d.latest?.[metric];
  return v == null || !Number.isFinite(+v) ? null : +v;
}

let charts = [];
async function render(){
  const content = document.getElementById('content');
  if (!selected.length){ renderChips(); content.innerHTML = '<div class="empty">Pick 2–4 markets above to compare them side-by-side.</div>'; return; }
  const datas = await Promise.all(selected.map(k =>
    fetch('/api/market?key=' + encodeURIComponent(k)).then(r => r.ok ? r.json() : null).catch(() => null)));
  renderChips(datas);
  const ok = datas.filter(Boolean);
  if (!ok.length){ content.innerHTML = '<div class="empty">no data</div>'; return; }

  // score comes from /api/markets rows? /api/market latest lacks score — pull opportunity via markets list once
  const scores = {};
  for (const type of ['county','metro']){
    if (!selected.some(k => k.startsWith(type))) continue;
    const d = await fetch('/api/markets?type=' + type).then(r => r.json()).catch(() => null);
    for (const r of d?.rows || []) if (selected.includes(r.key)) scores[r.key] = r.opportunity_score;
  }

  let html = '<table><thead><tr><th>Metric</th>' + datas.map((d, i) =>
    `<th style="color:${COLORS[i]}">${d?.region?.name ?? selected[i]}</th>`).join('') + '</tr></thead><tbody>';
  for (const [label, metric, fmt, best] of ROWS){
    const vals = datas.map((d, i) => metric === 'score' ? (scores[selected[i]] ?? null) : (d ? latestOf(d, metric) : null));
    let bestIdx = -1;
    if (best){
      const present = vals.map((v, i) => [v, i]).filter(([v]) => v != null);
      if (present.length > 1) bestIdx = (best === 'max'
        ? present.reduce((a, b) => (b[0] > a[0] ? b : a))
        : present.reduce((a, b) => (b[0] < a[0] ? b : a)))[1];
    }
    html += `<tr><td>${label}</td>` + vals.map((v, i) =>
      `<td class="${i === bestIdx ? 'best' : ''}">${fmt(v)}</td>`).join('') + '</tr>';
  }
  html += '</tbody></table>';
  html += '<div class="charts">' + ['zhvi','median_sale_price','dom','months_of_supply'].map(m =>
    `<div class="chartbox"><h3>${m.replace(/_/g,' ').toUpperCase()}</h3>
       <div class="lgd">${datas.map((d,i)=>`<span style="color:${COLORS[i]}">${d?.region?.name ?? ''}</span>`).join('')}</div>
       <div id="ch-${m}"></div></div>`).join('') + '</div>';
  content.innerHTML = html;

  charts.forEach(c => c.destroy()); charts = [];
  for (const m of ['zhvi','median_sale_price','dom','months_of_supply']){
    // union of periods across markets -> aligned series
    const periodSet = new Set();
    datas.forEach(d => (d?.series?.[m] || []).forEach(([p]) => periodSet.add(p)));
    const periods = [...periodSet].sort();
    if (periods.length < 2) { document.getElementById('ch-' + m).innerHTML = '<div class="empty" style="padding:14px">no series</div>'; continue; }
    const x = periods.map(p => new Date(p + 'T12:00:00Z').getTime() / 1000);
    const series = datas.map(d => {
      const map = new Map((d?.series?.[m] || []));
      return periods.map(p => map.has(p) ? +map.get(p) : null);
    });
    const el = document.getElementById('ch-' + m);
    const u = new uPlot({
      width: Math.max(el.clientWidth, 380), height: 190,
      series: [{}, ...datas.map((d, i) => ({ label: d?.region?.name ?? '', stroke: COLORS[i], width: 2, points: { show: false } }))],
      axes: [
        { stroke: '#8a93a3', grid: { stroke: '#222936' } },
        { stroke: '#8a93a3', grid: { stroke: '#222936' },
          values: (u, vals) => vals.map(v => m.includes('price') || m === 'zhvi' ? '$' + (v / 1000).toFixed(0) + 'k' : v) },
      ],
      legend: { show: false }, cursor: { points: { size: 5 } },
    }, [x, ...series], el);
    charts.push(u);
  }
}

loadRegions().then(render);
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>