← 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>