← back to Nationalrealestate
public/market.html
226 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 — Market Detail</title>
<link rel="stylesheet" href="/vendor/uPlot.min.css">
<style>
:root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;
--green:#5fae7c;--red:#c96a5a;}
*{box-sizing:border-box;}
html,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: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);}
main{max-width:1180px;margin:0 auto;padding:24px 18px 60px;}
#hdr{display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;margin-bottom:8px;}
#hdr h1{margin:0;font-size:26px;font-weight:700;}
#hdr .st{color:var(--muted);font-size:15px;}
#hdr .zhvi{font-size:22px;color:var(--gold);font-variant-numeric:tabular-nums;font-weight:700;}
.badge{display:inline-block;padding:3px 10px;border-radius:14px;font-size:12px;font-weight:700;
font-variant-numeric:tabular-nums;border:1px solid var(--line);}
.badge.up{color:var(--green);border-color:var(--green);}
.badge.down{color:var(--red);border-color:var(--red);}
#sub{color:var(--muted);font-size:12px;margin-bottom:20px;}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:16px;}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;}
.panel h3{margin:0 0 8px;font-size:13px;color:var(--gold);font-weight:700;text-transform:uppercase;
letter-spacing:.5px;}
.panel .nodata{color:var(--muted);font-size:12px;padding:30px 0;text-align:center;}
#stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;}
.stat{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 12px;}
.stat .k{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;}
.stat .v{font-size:17px;font-weight:700;font-variant-numeric:tabular-nums;margin-top:2px;}
#foot{margin-top:22px;color:var(--muted);font-size:11px;}
.u-legend{color:var(--muted);font-size:11px;}
.u-legend .u-label{color:var(--muted);}
</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> — Market Detail</div>
<nav>
<a href="/">Map</a>
<a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
<a href="/compare.html">Compare</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html">Admin</a>
</nav>
</div>
<main>
<div id="hdr"><h1>Loading…</h1></div>
<div id="sub"></div>
<div class="grid2" id="charts">
<div class="panel"><h3>ZHVI — Home Value Index</h3><div id="c_zhvi"></div></div>
<div class="panel"><h3>Median Sale Price</h3><div id="c_msp"></div></div>
<div class="panel"><h3>Inventory + New Listings</h3><div id="c_inv"></div></div>
<div class="panel"><h3>Days on Market + Months of Supply</h3><div id="c_dom"></div></div>
<div class="panel"><h3>ZORI — Observed Rent Index</h3><div id="c_zori"></div></div>
<div class="panel"><h3>FHFA HPI (all-transactions, NSA)</h3><div id="c_hpi"></div></div>
</div>
<div class="panel" style="margin-top:16px;">
<h3>Demographics & Derived</h3>
<div id="stats"></div>
</div>
<div class="grid2" style="margin-top:16px;">
<div class="panel">
<h3>Climate Risk <span style="color:var(--muted);font-weight:400;text-transform:none">— FEMA National Risk Index</span></h3>
<div id="climate" class="nodata">no climate data for this region</div>
</div>
<div class="panel">
<h3>Rental Market <span style="color:var(--muted);font-weight:400;text-transform:none">— HUD Fair Market Rents FY26</span></h3>
<div id="rental" class="nodata">no FMR data for this region</div>
</div>
</div>
<div class="panel" style="margin-top:16px;">
<h3>Parcels</h3>
<div class="nodata" id="parcels">Parcels — county data not yet loaded</div>
</div>
<div id="foot">Income, rent, population, vacancy, renter share: ACS 2023 5-yr vintage.
ZHVI/ZORI: Zillow Research. Sale metrics: Redfin Data Center. HPI: FHFA.</div>
</main>
<script src="/vendor/uPlot.iife.min.js"></script>
<script>
const qs = new URLSearchParams(location.search);
const key = qs.get('key') || 'county:06037';
const usd0 = v => v == null ? '—' : '$' + Math.round(v).toLocaleString();
const int0 = v => v == null ? '—' : Math.round(v).toLocaleString();
const pct1 = v => v == null ? '—' : (v * 100).toFixed(1) + '%';
const num2 = v => v == null ? '—' : (+v).toFixed(2);
const AXIS = { stroke: '#8a93a3', grid: { stroke: '#222936', width: 1 }, ticks: { stroke: '#222936' },
font: '11px -apple-system, Segoe UI, Roboto, sans-serif' };
function toSeries(pairs){
return [pairs.map(p => new Date(p[0] + 'T00:00:00Z').getTime() / 1000), pairs.map(p => p[1])];
}
function chart(elId, entries, fmt){
// entries: [{label, pairs, color, dash?}] — aligned onto the union of timestamps
const el = document.getElementById(elId);
const present = entries.filter(e => e.pairs && e.pairs.length);
if (!present.length){ el.innerHTML = '<div class="nodata">no data for this region</div>'; return; }
const tsSet = new Set();
for (const e of present) for (const p of e.pairs) tsSet.add(new Date(p[0] + 'T00:00:00Z').getTime() / 1000);
const ts = [...tsSet].sort((a, b) => a - b);
const idx = new Map(ts.map((t, i) => [t, i]));
const data = [ts];
for (const e of present){
const col = new Array(ts.length).fill(null);
for (const p of e.pairs) col[idx.get(new Date(p[0] + 'T00:00:00Z').getTime() / 1000)] = p[1];
data.push(col);
}
const w = el.closest('.panel').clientWidth - 32;
new uPlot({
width: Math.max(320, w), height: 220,
cursor: { points: { size: 6 } },
scales: { x: { time: true } },
axes: [ { ...AXIS }, { ...AXIS, values: (u, vals) => vals.map(v => fmt(v)) } ],
series: [ {},
...present.map(e => ({ label: e.label, stroke: e.color, width: 2, dash: e.dash,
points: { show: false }, value: (u, v) => fmt(v) })) ],
}, data, el);
}
(async function init(){
const res = await fetch('/api/market?key=' + encodeURIComponent(key));
if (!res.ok){ document.querySelector('#hdr h1').textContent = 'Region not found: ' + key; return; }
const d = await res.json();
const { region, series, latest } = d;
document.title = 'USRealEstate — ' + region.name;
const hdr = document.getElementById('hdr');
const yoy = latest.zhvi_yoy;
hdr.innerHTML = `<h1>${region.name}</h1><span class="st">${region.state_code || ''} · ${region.region_type}</span>` +
`<span class="zhvi">${usd0(latest.zhvi)}</span>` +
(yoy != null ? `<span class="badge ${yoy >= 0 ? 'up' : 'down'}">${yoy >= 0 ? '▲' : '▼'} ${pct1(Math.abs(yoy))} YoY</span>` : '');
document.getElementById('sub').textContent =
`${region.canonical_key} · population ${int0(region.population)} · 36-month history, all sources`;
chart('c_zhvi', [{ label: 'ZHVI', pairs: series.zhvi, color: '#c8a24b' }], usd0);
chart('c_msp', [{ label: 'Median Sale', pairs: series.median_sale_price, color: '#5f9ac0' }], usd0);
chart('c_inv', [
{ label: 'Inventory', pairs: series.inventory, color: '#c8a24b' },
{ label: 'New Listings', pairs: series.new_listings, color: '#5fae7c' },
], int0);
chart('c_dom', [
{ label: 'DOM', pairs: series.dom, color: '#c96a5a' },
{ label: 'Months Supply', pairs: series.months_of_supply, color: '#8a93a3', dash: [5, 5] },
], v => v == null ? '—' : (+v).toFixed(1));
chart('c_zori', [{ label: 'ZORI', pairs: series.zori, color: '#5fae7c' }], usd0);
chart('c_hpi', [{ label: 'HPI', pairs: series.hpi, color: '#b59b5c' }], v => v == null ? '—' : Math.round(v));
const STATS = [
['Median HH Income', usd0(latest.median_hh_income)],
['Median Gross Rent', usd0(latest.median_gross_rent)],
['Population', int0(latest.population ?? region.population)],
['Vacancy Rate', pct1(latest.vacancy_rate)],
['Renter Share', pct1(latest.renter_share)],
['Affordability (ZHVI/Income)', num2(latest.affordability)],
['Rent Yield (ZORI×12/ZHVI)', pct1(latest.rent_yield)],
['Sale-to-List', pct1(latest.sale_to_list)],
];
document.getElementById('stats').innerHTML = STATS.map(([k, v]) =>
`<div class="stat"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
if (latest.nri_risk_score != null) {
const riskWord = v => v >= 90 ? 'Very High' : v >= 70 ? 'High' : v >= 40 ? 'Moderate' : v >= 15 ? 'Low' : 'Very Low';
const CR = [
['Overall Risk', latest.nri_risk_score, true],
['Expected Annual Loss', latest.nri_eal_score, true],
['Social Vulnerability', latest.nri_sovi_score, false],
['Community Resilience', latest.nri_resl_score, false],
];
document.getElementById('climate').className = '';
document.getElementById('climate').innerHTML = CR.map(([k, v, word]) => `
<div class="stat"><div class="k">${k}</div>
<div class="v">${v.toFixed(0)}<span style="font-size:11px;color:var(--muted)">/100</span>
${word ? `<span style="font-size:11px;color:${v >= 70 ? '#f85149' : v >= 40 ? '#d29922' : '#3fb950'};margin-left:6px">${riskWord(v)}</span>` : ''}</div></div>`).join('');
}
if (latest.fmr_2br != null) {
const FMRS = [['Studio', latest.fmr_0br], ['1 BR', latest.fmr_1br], ['2 BR', latest.fmr_2br], ['3 BR', latest.fmr_3br], ['4 BR', latest.fmr_4br]];
const zoriNote = latest.zori != null
? `<div style="font-size:12px;color:var(--muted);margin-top:8px">ZORI observed rent: <b style="color:var(--gold)">${usd0(latest.zori)}</b>/mo · FMR 2BR ${latest.zori > latest.fmr_2br ? 'below' : 'above'} market</div>` : '';
document.getElementById('rental').className = '';
document.getElementById('rental').innerHTML = FMRS.map(([k, v]) => `
<div class="stat"><div class="k">${k}</div><div class="v">${usd0(v)}<span style="font-size:11px;color:var(--muted)">/mo</span></div></div>`).join('') + zoriNote;
}
// M-P1: counties with parcel coverage get a property-level search entry point
if (region.region_type === 'county') {
try {
const cov = await fetch('/api/property/coverage').then(r => r.json());
const hit = (cov.counties || []).find(c => c.county_fips === region.fips);
if (hit) {
const cf = region.fips, ph = cf === '06037' ? '9321 Ventura Way' : '1 5 Avenue';
document.getElementById('parcels').innerHTML =
`<form style="display:flex;gap:8px;justify-content:center;align-items:center;flex-wrap:wrap;"
onsubmit="event.preventDefault();location.href='/property.html?county=${cf}&loc='+encodeURIComponent(this.q.value);">
<span style="color:var(--fg);">${(+hit.parcel_count).toLocaleString()} parcels loaded —</span>
<input name="q" placeholder="search an address, e.g. ${ph}" autocomplete="off"
style="background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--fg);
padding:7px 12px;font-size:13px;min-width:260px;outline:none;">
<button type="submit" style="background:var(--gold);color:#0a0d13;border:0;border-radius:8px;
padding:7px 14px;font-weight:700;cursor:pointer;">Open property</button>
<a href="/property.html?county=${cf}" style="color:var(--gold);font-size:12px;">property detail →</a>
</form>`;
}
} catch {}
}
})();
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>