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