← back to Nationalrealestate
public/markets.html
169 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 — Markets Ranking</title>
<link rel="stylesheet" href="/crcp-grid.css">
<style>
:root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;
/* map CrcpGrid theme vars onto the USRE dark/gold system */
--cg-card:#11151d;--cg-line:#222936;--cg-ink:#e8ecf2;--cg-mut:#8a93a3;
--cg-acc:#c8a24b;--cg-accbg:rgba(200,162,75,.12);--cg-railbg:#0a0d13;--cg-topbg:rgba(17,21,29,.94);}
*{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);}
#typeSeg{margin-left:auto;display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
#typeSeg button{background:var(--panel);color:var(--muted);border:0;border-right:1px solid var(--line);
padding:7px 14px;font-size:12px;cursor:pointer;}
#typeSeg button:last-child{border-right:0;}
#typeSeg button.active{background:var(--gold);color:var(--bg);font-weight:700;}
#loading{padding:60px;text-align:center;color:var(--muted);}
.score{font-weight:700;color:var(--gold);}
</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> — Markets Ranking</div>
<nav>
<a href="/">Map</a>
<a href="/markets.html" class="active">Markets</a><a href="/commercial.html">Commercial</a>
<a href="/watchlist.html">Watchlist</a>
<a href="/admin.html">Admin</a>
</nav>
<span id="freshness" style="margin-left:auto;font-size:11px;color:var(--muted);white-space:nowrap;"></span>
<label id="thinToggle" style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;">
<input type="checkbox" id="showThin"> small markets
</label>
<span id="typeSeg" style="margin-left:0">
<button data-type="county">Counties</button>
<button data-type="metro">Metros</button>
<button data-type="state">States</button>
</span>
<a id="exportcsv" href="/api/markets.csv?type=county" download
style="font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:8px;
padding:5px 12px;text-decoration:none;white-space:nowrap;">⭳ CSV</a>
</div>
<div class="cg-shell">
<div class="cg-rail" id="cgRail"></div>
<div class="cg-main" id="cgMain"><div id="loading">loading markets…</div></div>
</div>
<script src="/crcp-grid.js"></script>
<script src="/col-resize.js"></script>
<script>
const THIN = 0.6; // data_completeness below this = dimmed row
let TYPE = localStorage.getItem('usreMkt.type');
if (TYPE !== 'county' && TYPE !== 'metro') TYPE = 'county';
const FIELDS = [
{ k: 'state', l: 'State', def: 1, col: 1 },
{ k: 'opportunity_score', l: 'Score', def: 1, col: 1, num: 1 },
{ k: 'rank', l: 'Rank', def: 1, col: 1, num: 1 },
{ k: 'momentum', l: 'Momentum', def: 0, col: 1, calc: r => sub100(r.momentum), num: 1 },
{ k: 'affordability_score', l: 'Afford. Score', def: 0, col: 1, calc: r => sub100(r.affordability_score), num: 1 },
{ k: 'rent_yield_score', l: 'Yield Score', def: 0, col: 1, calc: r => sub100(r.rent_yield_score), num: 1 },
{ k: 'inventory_trend', l: 'Inv. Trend', def: 0, col: 1, calc: r => sub100(r.inventory_trend), num: 1 },
{ k: 'velocity', l: 'Velocity', def: 0, col: 1, calc: r => sub100(r.velocity), num: 1 },
{ k: 'data_completeness', l: 'Data %', def: 1, col: 1, calc: r => r.data_completeness == null ? null : Math.round(r.data_completeness * 100), num: 1, suf: '%' },
{ k: 'zhvi', l: 'ZHVI', def: 1, col: 1, money: 1, calc: r => r.zhvi == null ? null : Math.round(r.zhvi) },
{ k: 'zhvi_yoy', l: 'ZHVI YoY', def: 1, col: 1, calc: r => pctv(r.zhvi_yoy), pct: 1 },
{ k: 'median_sale_price', l: 'Med. Sale', def: 1, col: 1, money: 1 },
{ k: 'median_sale_price_yoy', l: 'Sale YoY', def: 0, col: 1, calc: r => pctv(r.median_sale_price_yoy), pct: 1 },
{ k: 'inventory', l: 'Inventory', def: 0, col: 1, num: 1 },
{ k: 'dom', l: 'DOM', def: 1, col: 1, num: 1 },
{ k: 'months_of_supply', l: 'Mo. Supply', def: 0, col: 1, num: 1 },
{ k: 'sale_to_list', l: 'Sale/List', def: 0, col: 1, calc: r => pctv(r.sale_to_list), pct: 1 },
{ k: 'median_hh_income', l: 'HH Income', def: 1, col: 1, money: 1 },
{ k: 'rent_yield', l: 'Rent Yield', def: 1, col: 1, calc: r => pctv(r.rent_yield), pct: 1 },
{ k: 'affordability', l: 'ZHVI/Income', def: 0, col: 1, calc: r => r.affordability == null ? null : +(+r.affordability).toFixed(2), num: 1 },
{ k: 'population', l: 'Population', def: 1, col: 1, num: 1 },
{ k: 'name', l: 'Name', def: 0, col: 0 }, // title-column sort target only
];
function sub100(v){ return v == null ? null : Math.round(v * 100); }
function pctv(v){ return v == null ? null : +(v * 100).toFixed(2); }
// Left-rail filters: ONE facet per data point so the rail covers ALL data points.
// 'state' -> multi-select chips; every other field -> a min/max range. Derived from
// FIELDS (not hand-written) so it stays in sync, and each range reuses its field's
// `calc` so the bounds match the SCALED value shown in the grid (YoY %, 0-100
// sub-scores, rounded ZHVI, Data %, etc.) rather than the raw stored value.
const FILTERS = FIELDS
.filter(f => f.k !== 'name') // 'name' is the title/sort target, not a filter
.map(f => f.k === 'state'
? { k: 'state', l: 'State', type: 'chip', max: 60 }
: { k: f.k, l: f.l, type: 'range', calc: f.calc, money: f.money });
let grid = null;
let allRows = [];
// thin markets (<50k pop or <10 monthly sales) carry no rank and hide by default —
// a 795-person county topping the national list is noise, not signal
const showThinEl = document.getElementById('showThin');
showThinEl.checked = localStorage.getItem('usreMkt.showThin') === '1';
showThinEl.addEventListener('change', () => {
localStorage.setItem('usreMkt.showThin', showThinEl.checked ? '1' : '0');
if (grid) grid.setData(visibleRows());
});
function visibleRows(){ return showThinEl.checked ? allRows : allRows.filter(r => !r.thin_market); }
async function load(type){
TYPE = type;
localStorage.setItem('usreMkt.type', type);
document.getElementById('exportcsv').href = '/api/markets.csv?type=' + type;
document.querySelectorAll('#typeSeg button').forEach(b => b.classList.toggle('active', b.dataset.type === type));
const d = await fetch('/api/markets?type=' + type).then(r => r.json());
allRows = d.rows || [];
const rows = visibleRows();
const loading = document.getElementById('loading');
if (loading) loading.remove();
if (grid){ grid.setData(rows); return; }
grid = CrcpGrid.mount({
data: rows,
fields: FIELDS,
mount: '#cgMain',
rail: '#cgRail',
storageKey: 'usreMkt',
cols: 4,
view: 'list',
cap: 3500, // render all ~3.2k counties
title: r => ({ name: r.name, sub: (r.state || '') + ' · ' + r.key }),
titleCol: 'Market',
titleSortK: 'name',
search: r => `${r.name} ${r.state || ''} ${r.key}`,
sort: { k: 'opportunity_score', dir: -1 },
onRow: r => ({ cls: (r.data_completeness != null && r.data_completeness < THIN) ? 'dim' : '' }),
onRowClick: r => { location.href = '/market.html?key=' + encodeURIComponent(r.key); },
facets: FILTERS,
});
}
fetch('/api/health').then(r => r.json()).then(d => {
if (d.data_through) document.getElementById('freshness').textContent =
'data through ' + new Date(d.data_through + 'T12:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
});
document.getElementById('typeSeg').addEventListener('click', e => {
const b = e.target.closest('button[data-type]');
if (b && b.dataset.type !== TYPE) load(b.dataset.type);
});
// CSV export now honors the active filters — export the grid's filtered+sorted rows
// client-side instead of hitting the server endpoint (which dumps ALL rows).
document.getElementById('exportcsv').addEventListener('click', e => {
if (!grid) return; // fall back to the href if the grid isn't ready
e.preventDefault();
const n = grid.exportCSV({ filename: `usre-markets-${TYPE}.csv` });
console.log(`exported ${n} filtered ${TYPE} rows`);
});
load(TYPE);
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>