← back to Nationalrealestate
Compare Markets page: up to 4 markets side-by-side (best-value highlighting, 4 overlaid uPlot trend charts, localStorage-persisted selection) + nav links
502de06d6202dfb6417e25c7fb30a9491cedd536 · 2026-07-21 15:03:55 -0700 · Steve Abrams
Files touched
M public/admin.htmlA public/compare.htmlM public/index.htmlM public/market.htmlM public/watchlist.html
Diff
commit 502de06d6202dfb6417e25c7fb30a9491cedd536
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 21 15:03:55 2026 -0700
Compare Markets page: up to 4 markets side-by-side (best-value highlighting, 4 overlaid uPlot trend charts, localStorage-persisted selection) + nav links
---
public/admin.html | 1 +
public/compare.html | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 1 +
public/market.html | 1 +
public/watchlist.html | 1 +
5 files changed, 200 insertions(+)
diff --git a/public/admin.html b/public/admin.html
index c9d8845..6a37386 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -40,6 +40,7 @@
<nav>
<a href="/">Map</a>
<a href="/markets.html">Markets</a>
+ <a href="/compare.html">Compare</a>
<a href="/brokers.html">Brokers</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html" class="active">Admin</a>
diff --git a/public/compare.html b/public/compare.html
new file mode 100644
index 0000000..500097f
--- /dev/null
+++ b/public/compare.html
@@ -0,0 +1,196 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<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>
+</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="/compare.html" class="active">Compare</a>
+ <a href="/brokers.html">Brokers</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>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index cd64303..39079c1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -46,6 +46,7 @@
<nav>
<a href="/" class="active">Map</a>
<a href="/markets.html">Markets</a>
+ <a href="/compare.html">Compare</a>
<a href="/brokers.html">Brokers</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html">Admin</a>
diff --git a/public/market.html b/public/market.html
index 7a480f8..26cc27e 100644
--- a/public/market.html
+++ b/public/market.html
@@ -47,6 +47,7 @@
<nav>
<a href="/">Map</a>
<a href="/markets.html">Markets</a>
+ <a href="/compare.html">Compare</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html">Admin</a>
</nav>
diff --git a/public/watchlist.html b/public/watchlist.html
index a3dd6b8..7f8f485 100644
--- a/public/watchlist.html
+++ b/public/watchlist.html
@@ -43,6 +43,7 @@
<nav>
<a href="/">Map</a>
<a href="/markets.html">Markets</a>
+ <a href="/compare.html">Compare</a>
<a href="/brokers.html">Brokers</a>
<a href="/watchlist.html" class="active">Watchlist</a>
<a href="/admin.html">Admin</a>
← dd7b002 Map page: Top Markets strip (top-8 ranked, clickable to deta
·
back to Nationalrealestate
·
Market detail: Climate Risk (FEMA NRI) + Rental Market (HUD db8222e →