← back to Nationalrealestate
M4: deterministic opportunity scorer (5-component percentile model, weight renormalization, dense rank, 3994 regions scored), score-config.json, /api/markets endpoint, markets.html ranking dashboard on adapted CRCP grid (sort select + th-click sort + density slider persisted, thin-data dimming, county/metro toggle)
effc2fde2a48963af6ecfecdbc5789715e57f28d · 2026-07-21 13:01:26 -0700 · Steve Abrams
Files touched
A public/col-resize.jsA public/crcp-grid.cssA public/crcp-grid.jsA public/markets.htmlA score-config.jsonA src/score/opportunity.ts
Diff
commit effc2fde2a48963af6ecfecdbc5789715e57f28d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 21 13:01:26 2026 -0700
M4: deterministic opportunity scorer (5-component percentile model, weight renormalization, dense rank, 3994 regions scored), score-config.json, /api/markets endpoint, markets.html ranking dashboard on adapted CRCP grid (sort select + th-click sort + density slider persisted, thin-data dimming, county/metro toggle)
---
public/col-resize.js | 309 +++++++++++++++++++++++++++++++++++++++++++++++
public/crcp-grid.css | 65 ++++++++++
public/crcp-grid.js | 247 +++++++++++++++++++++++++++++++++++++
public/markets.html | 126 +++++++++++++++++++
score-config.json | 15 +++
src/score/opportunity.ts | 182 ++++++++++++++++++++++++++++
6 files changed, 944 insertions(+)
diff --git a/public/col-resize.js b/public/col-resize.js
new file mode 100644
index 0000000..66ba0c1
--- /dev/null
+++ b/public/col-resize.js
@@ -0,0 +1,309 @@
+/*!
+ * col-resize.js — spreadsheet-style draggable column resizing for any <table>.
+ * Zero dependencies. Drop-in: <script src="/col-resize.js" defer></script>
+ *
+ * Behaviour:
+ * • Hover a column's right edge → col-resize cursor → drag to widen/narrow.
+ * • Only the dragged column changes; the rest hold still (table-layout:fixed).
+ * • Double-click a handle → auto-fit that column to its content.
+ * • Widths persist in localStorage, keyed by page + table signature + column,
+ * so they survive reloads AND JS re-renders of the same table.
+ * • Auto-enhances tables added later (fetch-rendered grids) via MutationObserver.
+ *
+ * Opt out per table with <table data-no-resize>. Re-scan manually: ColResize.refresh().
+ */
+(function () {
+ 'use strict';
+ if (window.__colResizeLoaded) return;
+ window.__colResizeLoaded = true;
+
+ var MIN = 40; // px, smallest a column may shrink to
+ var LS = 'cr:'; // localStorage key prefix
+ var HANDLE_W = 8; // px hit-area of the drag handle
+ var lastResizeUp = 0; // timestamp of the last resize drag end (sort ignores that click)
+
+ // ---- persistence ----------------------------------------------------------
+ function tableSig(headers) {
+ // Stable id from header text + count — survives re-render & column reorder.
+ var s = headers.map(function (h) { return (h.textContent || '').trim(); }).join('|') + '#' + headers.length;
+ var hash = 0;
+ for (var i = 0; i < s.length; i++) { hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0; }
+ return 't' + (hash >>> 0).toString(36);
+ }
+ function keyFor(sig, idx) { return LS + location.pathname + ':' + sig + ':' + idx; }
+ function save(sig, idx, w) { try { localStorage.setItem(keyFor(sig, idx), String(Math.round(w))); } catch (e) {} }
+ function load(sig, idx) { try { var v = localStorage.getItem(keyFor(sig, idx)); return v ? parseFloat(v) : null; } catch (e) { return null; } }
+ function clear(sig, idx) { try { localStorage.removeItem(keyFor(sig, idx)); } catch (e) {} }
+
+ // ---- header-cell detection ------------------------------------------------
+ function headerCells(table) {
+ var thead = table.tHead;
+ if (thead && thead.rows.length) {
+ var row = thead.rows[thead.rows.length - 1];
+ if (row && row.cells.length) return Array.prototype.slice.call(row.cells);
+ }
+ // No <thead> — treat the first row's cells as headers.
+ var first = table.rows[0];
+ return first ? Array.prototype.slice.call(first.cells) : [];
+ }
+
+ // ---- colgroup: one <col> per column ---------------------------------------
+ function ensureColgroup(table, n) {
+ var cg = table.querySelector('colgroup[data-cr]');
+ if (cg && cg.children.length === n) return cg;
+ if (cg) cg.remove();
+ cg = document.createElement('colgroup');
+ cg.setAttribute('data-cr', '1');
+ for (var i = 0; i < n; i++) cg.appendChild(document.createElement('col'));
+ table.insertBefore(cg, table.firstChild);
+ return cg;
+ }
+
+ // ---- wrap table in a horizontal-scroll container --------------------------
+ function ensureScroll(table) {
+ var p = table.parentElement;
+ if (p && p.classList.contains('cr-scroll')) return p;
+ var wrap = document.createElement('div');
+ wrap.className = 'cr-scroll';
+ p.insertBefore(wrap, table);
+ wrap.appendChild(table);
+ return wrap;
+ }
+
+ // ---- enhance one table ----------------------------------------------------
+ function enhance(table) {
+ if (table.dataset.crDone === '1' || table.hasAttribute('data-no-resize')) return;
+ var headers = headerCells(table);
+ if (headers.length < 2) return; // nothing meaningful to resize
+ table.dataset.crDone = '1';
+
+ var sig = tableSig(headers);
+ var cols = ensureColgroup(table, headers.length).children;
+ ensureScroll(table);
+
+ // Snapshot current widths → explicit px, THEN switch to fixed layout so the
+ // browser stops redistributing. Saved widths override the snapshot.
+ var measured = headers.map(function (h) { return Math.max(MIN, Math.round(h.getBoundingClientRect().width)); });
+ table.style.tableLayout = 'fixed';
+ table.style.width = 'auto';
+ for (var i = 0; i < headers.length; i++) {
+ var saved = load(sig, i);
+ cols[i].style.width = (saved != null ? Math.max(MIN, saved) : measured[i]) + 'px';
+ }
+
+ // Add a drag handle to each header cell.
+ headers.forEach(function (th, idx) {
+ var cs = getComputedStyle(th);
+ if (cs.position === 'static') th.style.position = 'relative';
+ var handle = document.createElement('span');
+ handle.className = 'cr-handle';
+ handle.setAttribute('role', 'separator');
+ handle.setAttribute('aria-orientation', 'vertical');
+ handle.title = 'Drag to resize • double-click to auto-fit';
+ th.appendChild(handle);
+ wireHandle(handle, table, cols, headers, sig, idx);
+ });
+
+ addSorting(table, headers);
+ }
+
+ // ---- click-a-header-to-sort (asc → desc → original) -----------------------
+ function addSorting(table, headers) {
+ if (table.hasAttribute('data-no-sort')) return;
+ var headerRow = headers[0].parentNode;
+
+ headers.forEach(function (th, idx) {
+ if (th.querySelector('.cr-sort')) return; // already wired
+ th.classList.add('cr-sortable');
+ var ind = document.createElement('span');
+ ind.className = 'cr-sort';
+ th.appendChild(ind);
+
+ th.addEventListener('click', function (e) {
+ if (e.target.closest && e.target.closest('.cr-handle')) return; // it was a resize grab
+ if (Date.now() - lastResizeUp < 300) return; // a drag just ended
+ var dir = th.dataset.crSort === 'asc' ? 'desc' : th.dataset.crSort === 'desc' ? 'none' : 'asc';
+ sortBy(table, headers, headerRow, idx, dir);
+ headers.forEach(function (o) { o.dataset.crSort = ''; o.querySelector('.cr-sort').textContent = ''; });
+ th.dataset.crSort = dir;
+ th.querySelector('.cr-sort').textContent = dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : '';
+ });
+ });
+ }
+
+ function bodyRows(table, headerRow) {
+ var rows = [];
+ for (var b = 0; b < table.tBodies.length; b++) {
+ var tb = table.tBodies[b];
+ for (var i = 0; i < tb.rows.length; i++) if (tb.rows[i] !== headerRow) rows.push(tb.rows[i]);
+ }
+ return rows;
+ }
+
+ function cellText(row, idx) {
+ var c = row.cells[idx];
+ return c ? (c.textContent || '').trim() : '';
+ }
+ // Parse "$1,234.56", "45%", "-12" → number; non-numeric → NaN.
+ function num(s) {
+ if (!s) return NaN;
+ var n = parseFloat(s.replace(/[^0-9.\-]/g, ''));
+ return isNaN(n) ? NaN : n;
+ }
+ function isNumericColumn(rows, idx) {
+ var seen = 0;
+ for (var i = 0; i < rows.length && seen < 30; i++) {
+ var t = cellText(rows[i], idx);
+ if (!t) continue;
+ seen++;
+ if (isNaN(num(t))) return false;
+ }
+ return seen > 0;
+ }
+
+ function sortBy(table, headers, headerRow, idx, dir) {
+ var rows = bodyRows(table, headerRow);
+ if (!rows.length) return;
+ if (!table.__crOrig) table.__crOrig = rows.slice(); // remember natural order once
+
+ if (dir === 'none') {
+ var order = table.__crOrig.filter(function (r) { return r.parentNode; });
+ var dest0 = rows[0].parentNode;
+ order.forEach(function (r) { dest0.appendChild(r); });
+ return;
+ }
+
+ var numeric = isNumericColumn(rows, idx);
+ var mul = dir === 'asc' ? 1 : -1;
+ // Decorate → sort → undecorate keeps blanks last and the sort stable.
+ rows.map(function (r, i) { return { r: r, i: i, t: cellText(r, idx) }; })
+ .sort(function (a, b) {
+ var ae = a.t === '', be = b.t === '';
+ if (ae && be) return a.i - b.i;
+ if (ae) return 1; // blanks always sink
+ if (be) return -1;
+ var cmp;
+ if (numeric) cmp = num(a.t) - num(b.t);
+ else cmp = a.t.localeCompare(b.t, undefined, { numeric: true, sensitivity: 'base' });
+ return cmp !== 0 ? cmp * mul : a.i - b.i; // stable tiebreak
+ })
+ .forEach(function (o) { o.r.parentNode.appendChild(o.r); });
+ }
+
+ function wireHandle(handle, table, cols, headers, sig, idx) {
+ var startX = 0, startW = 0, dragging = false;
+
+ function down(e) {
+ dragging = true;
+ startX = (e.touches ? e.touches[0].clientX : e.clientX);
+ startW = cols[idx].getBoundingClientRect().width;
+ document.body.classList.add('cr-dragging');
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ window.addEventListener('touchmove', move, { passive: false });
+ window.addEventListener('touchend', up);
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ function move(e) {
+ if (!dragging) return;
+ var x = (e.touches ? e.touches[0].clientX : e.clientX);
+ var w = Math.max(MIN, startW + (x - startX));
+ cols[idx].style.width = w + 'px';
+ if (e.cancelable) e.preventDefault();
+ }
+ function up() {
+ if (!dragging) return;
+ dragging = false;
+ document.body.classList.remove('cr-dragging');
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ window.removeEventListener('touchmove', move);
+ window.removeEventListener('touchend', up);
+ lastResizeUp = Date.now();
+ save(sig, idx, cols[idx].getBoundingClientRect().width);
+ }
+ // Double-click → auto-fit this column to its content.
+ function autofit(e) {
+ e.preventDefault(); e.stopPropagation();
+ clear(sig, idx);
+ // Remember every column's current width, let this one go auto, measure, restore.
+ var widths = [];
+ for (var i = 0; i < cols.length; i++) widths[i] = cols[i].style.width;
+ table.style.tableLayout = 'auto';
+ cols[idx].style.width = '';
+ var natural = Math.max(MIN, Math.round(headers[idx].getBoundingClientRect().width));
+ for (var j = 0; j < cols.length; j++) cols[j].style.width = widths[j];
+ cols[idx].style.width = natural + 'px';
+ table.style.tableLayout = 'fixed';
+ save(sig, idx, natural);
+ }
+
+ handle.addEventListener('pointerdown', down);
+ handle.addEventListener('dblclick', autofit);
+ // Fallback for browsers without Pointer Events.
+ if (!window.PointerEvent) handle.addEventListener('touchstart', down, { passive: false });
+ }
+
+ // ---- inject the small stylesheet once -------------------------------------
+ function injectCSS() {
+ if (document.getElementById('cr-style')) return;
+ var css = ''
+ + '.cr-scroll{overflow-x:auto;max-width:100%;}'
+ + '.cr-handle{position:absolute;top:0;right:-' + (HANDLE_W / 2) + 'px;width:' + HANDLE_W + 'px;height:100%;'
+ + 'cursor:col-resize;user-select:none;touch-action:none;z-index:5;}'
+ + '.cr-handle:hover::after,.cr-dragging .cr-handle::after{content:"";position:absolute;top:0;left:50%;'
+ + 'width:2px;height:100%;transform:translateX(-50%);background:currentColor;opacity:.55;}'
+ + 'body.cr-dragging{cursor:col-resize!important;user-select:none!important;}'
+ + 'body.cr-dragging *{cursor:col-resize!important;}'
+ + 'th.cr-sortable{cursor:pointer;}'
+ + 'th.cr-sortable:hover{background:rgba(127,127,127,.12);}'
+ + '.cr-sort{font-size:.85em;opacity:.8;white-space:nowrap;}';
+ var s = document.createElement('style');
+ s.id = 'cr-style';
+ s.textContent = css;
+ document.head.appendChild(s);
+ }
+
+ // ---- scan + observe -------------------------------------------------------
+ function scan(root) {
+ var tables = (root || document).querySelectorAll('table');
+ for (var i = 0; i < tables.length; i++) {
+ try { enhance(tables[i]); } catch (e) { /* never let one bad table break the page */ }
+ }
+ }
+
+ var pending = false;
+ function scheduleScan() {
+ if (pending) return;
+ pending = true;
+ (window.requestAnimationFrame || setTimeout)(function () { pending = false; scan(document); });
+ }
+
+ function start() {
+ injectCSS();
+ scan(document);
+ if (window.MutationObserver) {
+ new MutationObserver(function (muts) {
+ for (var i = 0; i < muts.length; i++) {
+ if (muts[i].addedNodes && muts[i].addedNodes.length) { scheduleScan(); break; }
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+ }
+ }
+
+ window.ColResize = {
+ refresh: function () { scan(document); },
+ reset: function () { // wipe all saved widths for this page
+ try {
+ Object.keys(localStorage).forEach(function (k) {
+ if (k.indexOf(LS + location.pathname + ':') === 0) localStorage.removeItem(k);
+ });
+ } catch (e) {}
+ location.reload();
+ }
+ };
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
+ else start();
+})();
diff --git a/public/crcp-grid.css b/public/crcp-grid.css
new file mode 100644
index 0000000..b8078be
--- /dev/null
+++ b/public/crcp-grid.css
@@ -0,0 +1,65 @@
+/* crcp-grid.css — styles for the reusable CrcpGrid engine. Namespaced .cg-* so it never
+ * collides with a host page. Colors read the host's CSS vars with safe fallbacks, so the
+ * engine inherits each page's theme (condos dark, fha-loans glass, sales gold) automatically. */
+.cg-shell{display:flex;align-items:flex-start;}
+.cg-rail{flex:0 0 250px;align-self:stretch;border-right:1px solid var(--cg-line,#2a313c);background:var(--cg-railbg,rgba(0,0,0,.18));position:sticky;top:0;max-height:100vh;overflow-y:auto;padding-bottom:30px;}
+.cg-main{flex:1 1 auto;min-width:0;--cg-cols:3;}
+.cg-sec{padding:12px 15px;border-bottom:1px solid var(--cg-line,#2a313c);}
+.cg-sec h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--cg-mut,#8b949e);cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;}
+.cg-sec h4::after{content:'▾';font-size:11px;margin-left:8px;transition:transform .15s;}
+.cg-sec.collapsed h4::after{transform:rotate(-90deg);}
+.cg-sec.collapsed > *:not(h4){display:none !important;}
+.cg-chips{display:flex;flex-wrap:wrap;gap:6px;}
+.cg-chip{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
+.cg-chip:hover{color:var(--cg-ink,#e6edf3);}
+.cg-chip.active{border-color:var(--cg-acc,#58a6ff);color:var(--cg-acc,#58a6ff);background:var(--cg-accbg,rgba(88,166,255,.10));}
+.cg-ct{color:var(--cg-mut,#8b949e);font-size:10px;margin-left:4px;}
+.cg-row2{display:flex;gap:8px;}
+.cg-fld{flex:1;}
+.cg-fld label{display:block;font-size:10px;color:var(--cg-mut,#8b949e);margin-bottom:3px;}
+.cg-fld input{width:100%;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:7px 9px;font-size:13px;}
+.cg-ftogs{display:flex;flex-direction:column;gap:1px;max-height:300px;overflow-y:auto;}
+.cg-ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--cg-ink,#e6edf3);cursor:pointer;padding:3px 4px;border-radius:6px;}
+.cg-ftog:hover{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-mini{margin-top:8px;display:flex;gap:5px;}
+.cg-mini button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;}
+.cg-mini button:hover{border-color:var(--cg-acc,#58a6ff);color:var(--cg-ink,#e6edf3);}
+.cg-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:13px 18px;border-bottom:1px solid var(--cg-line,#2a313c);position:sticky;top:0;background:var(--cg-topbg,rgba(14,17,22,.94));backdrop-filter:blur(6px);z-index:6;}
+.cg-q{min-width:240px;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 11px;font-size:13px;}
+.cg-seg{display:inline-flex;border:1px solid var(--cg-line,#2a313c);border-radius:8px;overflow:hidden;}
+.cg-seg button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:0;border-right:1px solid var(--cg-line,#2a313c);padding:7px 12px;font-size:12px;cursor:pointer;}
+.cg-seg button:last-child{border-right:0;}
+.cg-seg button.active{background:var(--cg-acc,#1f6feb);color:#fff;}
+.cg-dens{font-size:12px;color:var(--cg-mut,#8b949e);display:flex;align-items:center;gap:7px;}
+.cg-dens input{accent-color:var(--cg-acc,#58a6ff);}
+.cg-count{margin-left:auto;font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-count b{color:var(--cg-ink,#e6edf3);}
+.cg-grid{display:grid;gap:16px;padding:18px;grid-template-columns:repeat(var(--cg-cols,3),minmax(0,1fr));}
+.cg-grid.cg-listhost{display:block;}
+.cg-card{background:var(--cg-card,#161b22);border:1px solid var(--cg-line,#2a313c);border-radius:12px;padding:15px;display:flex;flex-direction:column;gap:5px;}
+.cg-card.red{border-color:var(--cg-red,#f85149);}
+.cg-name{font-weight:600;font-size:14px;color:var(--cg-ink,#e6edf3);}
+.cg-sub{font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-r{display:flex;justify-content:space-between;font-size:12px;gap:10px;}
+.cg-r .cg-k{color:var(--cg-mut,#8b949e);}
+.cg-empty{padding:50px;text-align:center;color:var(--cg-mut,#8b949e);}
+.cg-listwrap{overflow-x:auto;margin:18px;border:1px solid var(--cg-line,#2a313c);border-radius:12px;}
+.cg-tbl{width:100%;border-collapse:collapse;font-size:12.5px;white-space:nowrap;}
+.cg-tbl th,.cg-tbl td{text-align:right;padding:9px 13px;border-top:1px solid var(--cg-line,#2a313c);}
+.cg-tbl th{position:sticky;top:0;background:var(--cg-railbg,#0b0e13);color:var(--cg-mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:0;}
+.cg-tbl td.cg-stick,.cg-tbl th.cg-stick{text-align:left;position:sticky;left:0;background:var(--cg-card,#161b22);}
+.cg-tbl th.cg-stick{background:var(--cg-railbg,#0b0e13);}
+.cg-tbl tr:hover td{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-tbl tr.red td{background:rgba(248,81,73,.07);}
+.cg-la{font-weight:600;color:var(--cg-ink,#e6edf3);}
+.cg-lc{color:var(--cg-mut,#8b949e);font-size:11px;}
+@media(max-width:820px){.cg-rail{display:none;}}
+/* --- USRE adaptations (nationalrealestate) --- */
+.cg-sort{background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 10px;font-size:13px;}
+.cg-sortth{cursor:pointer;user-select:none;}
+.cg-sortth:hover{color:var(--cg-ink,#e6edf3);}
+/* density slider drives list-view row padding + font size via --cg-dens (1..12) */
+.cg-tbl{font-size:calc(15.5px - var(--cg-dens,3)*0.45px);font-variant-numeric:tabular-nums;}
+.cg-tbl th,.cg-tbl td{padding:calc(14px - var(--cg-dens,3)*1px) calc(16px - var(--cg-dens,3)*0.8px);}
+.cg-tbl tr[data-ri]{cursor:pointer;}
+.cg-tbl tr.dim td,.cg-card.dim{opacity:.45;}
diff --git a/public/crcp-grid.js b/public/crcp-grid.js
new file mode 100644
index 0000000..38cae2a
--- /dev/null
+++ b/public/crcp-grid.js
@@ -0,0 +1,247 @@
+/* crcp-grid.js — reusable Amazon-style faceted grid engine for the CRE fleet.
+ * Config-driven, theme-agnostic (inherits the host page's CSS vars via cg-grid.css).
+ * One page = one CrcpGrid.mount(opts) call. Mirrors the proven condos.html engine so
+ * every list page gets the SAME behavior: left-rail facets, per-field show/hide toggles,
+ * grid/list view toggle, density slider, live search, localStorage persistence.
+ *
+ * opts = {
+ * data: [row,...], // all rows (client-side faceting)
+ * fields: [{k,l,def,col,badge,money,num,date,calc,suf,pct}], // every data point (card rows + list cols)
+ * facets: [{k,l,type:'chip'|'range',calc,order,max}], // rail filters (auto-derived if omitted)
+ * mount: '#cgMain', // container for topbar + grid
+ * rail: '#cgRail', // container for the left rail
+ * title: row => ({name, sub}),// card/list title + subline
+ * search: row => 'text blob', // searchable string
+ * badgeHtml: row => '<span>...'|'',// optional status badge html (card + list)
+ * storageKey:'fha', // localStorage namespace
+ * cols: 3, // default density
+ * onRow: row => ({cls}), // optional per-row class hook (e.g. red flag)
+ * onRowClick:row => {}, // optional whole-row click handler (list + cards)
+ * sort: {k:'score',dir:-1}, // default sort (USRE adaptation; persisted, select + th-click)
+ * }
+ * USRE adaptations (nationalrealestate): sort <select> in the topbar + clickable list-view
+ * column headers (both persisted), density slider also drives list row padding/font via
+ * --cg-dens, onRowClick hook. Engine otherwise identical to the CRCP original.
+ */
+(function (global) {
+ const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
+ const $ = (sel, root) => (root || document).querySelector(sel);
+
+ function fmt(v, f) {
+ if (v == null || v === '') return '—';
+ if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
+ if (f.pct) return (+v).toFixed(2) + '%';
+ if (f.num) return (+v).toLocaleString() + (f.suf || '');
+ if (f.date) { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
+ return esc(v) + (f.suf || '');
+ }
+
+ function CrcpGrid(opts) {
+ const O = opts;
+ const KEY = O.storageKey || 'cg';
+ const fields = O.fields;
+ const data = O.data || [];
+ const fval = (row, f) => f.calc ? f.calc(row) : row[f.k];
+
+ // ---- field visibility (persisted) ----
+ const VIS = (function () {
+ let s = {}; try { s = JSON.parse(localStorage.getItem(KEY + 'Fields') || '{}'); } catch (e) {}
+ const o = {}; fields.forEach(f => o[f.k] = (f.k in s) ? !!s[f.k] : !!f.def); return o;
+ })();
+ const saveVis = () => { try { localStorage.setItem(KEY + 'Fields', JSON.stringify(VIS)); } catch (e) {} };
+
+ // ---- auto-derive facets if not provided ----
+ let facets = O.facets;
+ if (!facets) {
+ facets = [];
+ fields.forEach(f => {
+ if (f.badge || f.k === 'title') return;
+ const vals = data.map(r => fval(r, f)).filter(v => v != null && v !== '');
+ if (!vals.length) return;
+ const numeric = f.money || f.num || f.pct || vals.every(v => typeof v === 'number' || /^\d+(\.\d+)?$/.test(v));
+ const uniq = new Set(vals.map(String));
+ if (!numeric && uniq.size <= 24) facets.push({ k: f.k, l: f.l, type: 'chip', calc: f.calc });
+ else if (numeric) facets.push({ k: f.k, l: f.l, type: 'range', calc: f.calc, money: f.money });
+ });
+ }
+
+ // ---- sort state (persisted) ----
+ let SORT = O.sort || null; // {k, dir:1|-1}; k='' = source order
+ try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
+ const saveSort = () => { try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e) {} };
+ function sortRows(rows) {
+ if (!SORT || !SORT.k) return rows;
+ const f = fields.find(x => x.k === SORT.k);
+ if (!f) return rows;
+ const dir = SORT.dir || 1;
+ return [...rows].sort((a, b) => {
+ const va = fval(a, f), vb = fval(b, f);
+ const na = va == null || va === '', nb = vb == null || vb === '';
+ if (na && nb) return 0; if (na) return 1; if (nb) return -1; // nulls last either dir
+ if (typeof va === 'number' || typeof vb === 'number' || f.money || f.num || f.pct)
+ return ((+va) - (+vb)) * dir;
+ return String(va).localeCompare(String(vb)) * dir;
+ });
+ }
+
+ // ---- filter state ----
+ const F = { q: '', chips: {}, ranges: {} };
+ facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k] = new Set(); else F.ranges[fc.k] = { min: null, max: null }; });
+
+ const gv = (row, fc) => fc.calc ? fc.calc(row) : row[fc.k];
+ function pass(row) {
+ for (const fc of facets) {
+ if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set.size && !set.has(String(gv(row, fc)))) return false; }
+ else { const r = F.ranges[fc.k], v = gv(row, fc);
+ if (r.min != null && !(+v >= r.min)) return false;
+ if (r.max != null && !(+v <= r.max)) return false; }
+ }
+ if (F.q) { const blob = (O.search ? O.search(row) : fields.map(f => fval(row, f)).join(' ')).toLowerCase(); if (!blob.includes(F.q)) return false; }
+ return true;
+ }
+
+ // ---- rail ----
+ function buildRail() {
+ const rail = $(O.rail);
+ let html = '';
+ facets.forEach(fc => {
+ if (fc.type === 'chip') {
+ const counts = {};
+ data.forEach(r => { const v = gv(r, fc); if (v != null && v !== '') counts[v] = (counts[v] || 0) + 1; });
+ const ent = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, fc.max || 20);
+ if (!ent.length) return;
+ html += `<div class="cg-sec collapsed"><h4>${esc(fc.l)}</h4><div class="cg-chips" data-facet="${esc(fc.k)}">` +
+ ent.map(([k, c]) => `<span class="cg-chip" data-v="${esc(k)}">${esc(k)}<span class="cg-ct">${c}</span></span>`).join('') + `</div></div>`;
+ } else {
+ html += `<div class="cg-sec collapsed"><h4>${esc(fc.l)}</h4><div class="cg-row2" data-facet="${esc(fc.k)}">` +
+ `<div class="cg-fld"><label>Min</label><input type="number" class="cg-min" placeholder="any"></div>` +
+ `<div class="cg-fld"><label>Max</label><input type="number" class="cg-max" placeholder="any"></div></div></div>`;
+ }
+ });
+ html += `<div class="cg-sec"><h4>Fields — show / hide</h4><div class="cg-ftogs">` +
+ fields.map(f => `<label class="cg-ftog"><input type="checkbox" data-fk="${esc(f.k)}" ${VIS[f.k] ? 'checked' : ''}><span>${esc(f.l)}</span>${f.col ? '<span class="cg-ct" title="List column">▤</span>' : ''}</label>`).join('') +
+ `</div><div class="cg-mini"><button data-all="1">All</button><button data-none="1">None</button><button data-def="1">Default</button></div></div>`;
+ rail.innerHTML = html;
+
+ // wire facets
+ rail.querySelectorAll('.cg-chips').forEach(box => box.addEventListener('click', e => {
+ const c = e.target.closest('.cg-chip'); if (!c) return;
+ const set = F.chips[box.dataset.facet]; const v = c.dataset.v;
+ set.has(v) ? set.delete(v) : set.add(v); c.classList.toggle('active'); render();
+ }));
+ rail.querySelectorAll('.cg-row2').forEach(box => {
+ const k = box.dataset.facet;
+ box.querySelector('.cg-min').addEventListener('input', e => { F.ranges[k].min = e.target.value ? +e.target.value : null; render(); });
+ box.querySelector('.cg-max').addEventListener('input', e => { F.ranges[k].max = e.target.value ? +e.target.value : null; render(); });
+ });
+ // field toggles
+ rail.querySelector('.cg-ftogs').addEventListener('change', e => { const cb = e.target.closest('input[data-fk]'); if (!cb) return; VIS[cb.dataset.fk] = cb.checked; saveVis(); render(); });
+ const mini = rail.querySelector('.cg-mini');
+ mini.addEventListener('click', e => {
+ const b = e.target.closest('button'); if (!b) return;
+ if (b.dataset.all) fields.forEach(f => VIS[f.k] = true);
+ else if (b.dataset.none) fields.forEach(f => VIS[f.k] = false);
+ else if (b.dataset.def) fields.forEach(f => VIS[f.k] = !!f.def);
+ saveVis(); buildRail(); render();
+ });
+ // collapsible headers
+ rail.querySelectorAll('.cg-sec > h4').forEach(h => h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed')));
+ }
+
+ // ---- renderers ----
+ function cards(rows) {
+ const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
+ return rows.map((r, i) => {
+ const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+ const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+ const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+ return `<div class="cg-card ${rc}" data-ri="${i}">` +
+ `<div class="cg-name">${nm}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
+ (O.badgeHtml ? O.badgeHtml(r) : '') +
+ vis.map(f => `<div class="cg-r"><span class="cg-k">${esc(f.l)}</span><span>${fmt(fval(r, f), f)}</span></div>`).join('') +
+ `</div>`;
+ }).join('');
+ }
+ function list(rows) {
+ const cols = fields.filter(f => f.col && VIS[f.k] && !f.badge);
+ const arrow = k => (SORT && SORT.k === k) ? (SORT.dir < 0 ? ' ▼' : ' ▲') : '';
+ const titleK = O.titleSortK || '';
+ const head = `<th class="cg-stick cg-sortth" data-k="${esc(titleK)}">${esc((O.titleCol || 'Name'))}${arrow(titleK)}</th>` +
+ cols.map(f => `<th class="cg-sortth" data-k="${esc(f.k)}">${esc(f.l)}${arrow(f.k)}</th>`).join('');
+ const body = rows.map((r, i) => {
+ const t = O.title ? O.title(r) : { name: '', sub: '' };
+ const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+ const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+ return `<tr class="${rc}" data-ri="${i}"><td class="cg-stick"><span class="cg-la">${nm}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
+ cols.map(f => `<td>${fmt(fval(r, f), f)}</td>`).join('') + `</tr>`;
+ }).join('');
+ return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+ }
+
+ let VIEW = localStorage.getItem(KEY + 'View') || O.view || 'grid';
+ const CAP = O.cap || 1500; // DOM render cap for perf; count still reflects the full match set
+ let lastShown = [];
+ function render() {
+ const rows = sortRows(data.filter(pass));
+ const shown = rows.length > CAP ? rows.slice(0, CAP) : rows;
+ lastShown = shown;
+ const g = $(O.mount + ' .cg-grid');
+ if (!rows.length) { g.className = 'cg-grid'; g.innerHTML = '<div class="cg-empty">no matches for these filters.</div>'; }
+ else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = list(shown); }
+ else { g.className = 'cg-grid'; g.innerHTML = cards(shown); }
+ const cnt = $(O.mount + ' .cg-count');
+ if (cnt) cnt.innerHTML = `<b>${rows.length.toLocaleString()}</b> of ${data.length.toLocaleString()}` + (rows.length > CAP ? ` <span style="opacity:.7">(first ${CAP.toLocaleString()} shown)</span>` : '');
+ const sel = $(O.mount + ' .cg-sort');
+ if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+ }
+
+ // ---- topbar ----
+ function buildTop() {
+ const m = $(O.mount);
+ const sortable = fields.filter(f => f.col && !f.badge);
+ const sortOpts = `<option value="">Sort…</option>` + sortable.map(f =>
+ `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
+ m.insertAdjacentHTML('afterbegin',
+ `<div class="cg-top">` +
+ `<input type="text" class="cg-q" placeholder="search…">` +
+ `<select class="cg-sort" title="Sort">${sortOpts}</select>` +
+ `<span class="cg-seg"><button data-view="grid" class="${VIEW === 'grid' ? 'active' : ''}">▦ Grid</button><button data-view="list" class="${VIEW === 'list' ? 'active' : ''}">☰ List</button></span>` +
+ `<label class="cg-dens">Density <input type="range" class="cg-cols" min="1" max="12" step="1" value="${O.cols || 3}"></label>` +
+ `<span class="cg-count"></span></div><div class="cg-grid"></div>`);
+ const q = m.querySelector('.cg-q');
+ q.addEventListener('input', () => { clearTimeout(q._t); q._t = setTimeout(() => { F.q = q.value.trim().toLowerCase(); render(); }, 160); });
+ m.querySelector('.cg-sort').addEventListener('change', e => {
+ const v = e.target.value;
+ if (!v) SORT = null;
+ else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
+ saveSort(); render();
+ });
+ m.querySelector('.cg-seg').addEventListener('click', e => { const b = e.target.closest('button[data-view]'); if (!b) return; VIEW = b.dataset.view; localStorage.setItem(KEY + 'View', VIEW); m.querySelectorAll('.cg-seg button').forEach(x => x.classList.toggle('active', x.dataset.view === VIEW)); render(); });
+ const cols = m.querySelector('.cg-cols');
+ const setCols = v => { m.style.setProperty('--cg-cols', v); m.style.setProperty('--cg-dens', v); localStorage.setItem(KEY + 'Cols', v); };
+ cols.addEventListener('input', e => setCols(e.target.value));
+ const sc = localStorage.getItem(KEY + 'Cols'); if (sc) { cols.value = sc; }
+ setCols(cols.value);
+ // th-click sorting + whole-row click (delegated; grid re-renders under us)
+ m.addEventListener('click', e => {
+ const th = e.target.closest('.cg-sortth');
+ if (th && th.dataset.k) {
+ const k = th.dataset.k;
+ SORT = (SORT && SORT.k === k) ? { k, dir: -SORT.dir } : { k, dir: -1 };
+ saveSort(); render(); return;
+ }
+ if (O.onRowClick && !e.target.closest('a')) {
+ const tr = e.target.closest('tr[data-ri]');
+ if (tr) { O.onRowClick(lastShown[+tr.dataset.ri]); return; }
+ const card = e.target.closest('.cg-card[data-ri]');
+ if (card) O.onRowClick(lastShown[+card.dataset.ri]);
+ }
+ });
+ }
+
+ buildTop(); buildRail(); render();
+ return { render, setData(d) { O.data.length = 0; d.forEach(x => O.data.push(x)); buildRail(); render(); } };
+ }
+
+ global.CrcpGrid = { mount: opts => new CrcpGrid(opts) };
+})(window);
diff --git a/public/markets.html b/public/markets.html
new file mode 100644
index 0000000..60be745
--- /dev/null
+++ b/public/markets.html
@@ -0,0 +1,126 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<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>
+</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="/watchlist.html">Watchlist</a>
+ <a href="/admin.html">Admin</a>
+ </nav>
+ <span id="typeSeg">
+ <button data-type="county">Counties</button>
+ <button data-type="metro">Metros</button>
+ </span>
+</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); }
+
+let grid = null;
+async function load(type){
+ TYPE = type;
+ localStorage.setItem('usreMkt.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());
+ const rows = d.rows || [];
+ 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: [
+ { k: 'state', l: 'State', type: 'chip', max: 60 },
+ { k: 'opportunity_score', l: 'Score', type: 'range' },
+ { k: 'zhvi', l: 'ZHVI', type: 'range', money: 1 },
+ { k: 'population', l: 'Population', type: 'range' },
+ { k: 'dom', l: 'DOM', type: 'range' },
+ ],
+ });
+}
+document.getElementById('typeSeg').addEventListener('click', e => {
+ const b = e.target.closest('button[data-type]');
+ if (b && b.dataset.type !== TYPE) load(b.dataset.type);
+});
+load(TYPE);
+</script>
+</body>
+</html>
diff --git a/score-config.json b/score-config.json
new file mode 100644
index 0000000..1874792
--- /dev/null
+++ b/score-config.json
@@ -0,0 +1,15 @@
+{
+ "weights": {
+ "momentum": 0.25,
+ "affordability": 0.20,
+ "rent_yield": 0.20,
+ "inventory_trend": 0.15,
+ "velocity": 0.20
+ },
+ "constants": {
+ "inventory_trend_window_months": 12,
+ "score_scale": 100,
+ "clamp": [0, 100],
+ "thin_data_threshold": 0.6
+ }
+}
diff --git a/src/score/opportunity.ts b/src/score/opportunity.ts
new file mode 100644
index 0000000..264e823
--- /dev/null
+++ b/src/score/opportunity.ts
@@ -0,0 +1,182 @@
+/**
+ * Opportunity scorer — deterministic, NO LLM. Counties and metros scored separately
+ * (percentiles within region_type). ONE frozen SQL snapshot read, then pure TS math,
+ * so same-day re-runs produce byte-identical components.
+ *
+ * Components (each a 0-1 sub-score):
+ * momentum = percentile of blend(zhvi_yoy, redfin median_sale_price_yoy fallback)
+ * affordability = INVERSE percentile of zhvi / median_hh_income
+ * rent_yield = percentile of derived rent_yield
+ * inventory_trend = INVERSE percentile of 12-mo % change in months_of_supply (tightening = higher)
+ * velocity = mean(inverse DOM percentile, sale_to_list percentile)
+ * Null component -> dropped + weights renormalized; data_completeness = fraction of 5 present.
+ * opportunity_score = renormalized weighted sum x 100, clamped 0-100.
+ * Rank = dense by score desc within region_type.
+ */
+import { readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { query, pool } from '../../db/pool.ts';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const CONFIG = JSON.parse(readFileSync(join(__dirname, '..', '..', 'score-config.json'), 'utf8'));
+const WEIGHTS: Record<string, number> = CONFIG.weights;
+const COMPONENTS = ['momentum', 'affordability', 'rent_yield', 'inventory_trend', 'velocity'] as const;
+
+interface SnapRow { region_id: number; region_type: string; metric: string; value: string }
+
+/** Mid-rank percentile in [0,1] over a sorted array. Deterministic under ties. */
+function percentiler(values: number[]): (v: number) => number {
+ const sorted = [...values].sort((a, b) => a - b);
+ const n = sorted.length;
+ if (n <= 1) return () => 0.5;
+ const lowerBound = (v: number) => { let lo = 0, hi = n; while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] < v) lo = m + 1; else hi = m; } return lo; };
+ const upperBound = (v: number) => { let lo = 0, hi = n; while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] <= v) lo = m + 1; else hi = m; } return lo; };
+ return (v: number) => ((lowerBound(v) + upperBound(v) - 1) / 2) / (n - 1);
+}
+
+function round6(x: number): number { return Math.round(x * 1e6) / 1e6; }
+
+async function main() {
+ // ---- single frozen snapshot read ----
+ const snap = await query<SnapRow>(
+ `WITH latest AS (
+ SELECT DISTINCT ON (ms.region_id, ms.metric)
+ ms.region_id, rg.region_type, ms.metric, ms.value, ms.period
+ FROM metric_series ms
+ JOIN region rg ON rg.id = ms.region_id AND rg.region_type IN ('county','metro')
+ WHERE ms.metric IN ('zhvi_yoy','median_sale_price_yoy','affordability','rent_yield',
+ 'months_of_supply','dom','sale_to_list')
+ ORDER BY ms.region_id, ms.metric, ms.period DESC
+ )
+ SELECT region_id, region_type, metric, value::text AS value FROM latest
+ UNION ALL
+ SELECT l.region_id, l.region_type, 'months_of_supply_prior' AS metric, ms.value::text AS value
+ FROM latest l
+ JOIN metric_series ms
+ ON ms.region_id = l.region_id AND ms.metric = 'months_of_supply'
+ AND ms.period = l.period - INTERVAL '12 months'
+ WHERE l.metric = 'months_of_supply'
+ ORDER BY region_id, metric`,
+ );
+
+ const byRegion = new Map<number, { type: string; m: Record<string, number> }>();
+ for (const r of snap.rows) {
+ let o = byRegion.get(r.region_id);
+ if (!o) { o = { type: r.region_type, m: {} }; byRegion.set(r.region_id, o); }
+ o.m[r.metric] = Number(r.value);
+ }
+
+ const scoreDateR = await query<{ d: string }>(`SELECT CURRENT_DATE::text AS d`);
+ const scoreDate = scoreDateR.rows[0].d;
+
+ let totalUpserts = 0;
+ for (const regionType of ['county', 'metro']) {
+ const regions = [...byRegion.entries()]
+ .filter(([, o]) => o.type === regionType)
+ .sort((a, b) => a[0] - b[0]); // deterministic order
+
+ // raw inputs per region
+ type Raw = {
+ momentum_raw: number | null; affordability_raw: number | null; rent_yield_raw: number | null;
+ mos_change: number | null; dom: number | null; sale_to_list: number | null;
+ zhvi_yoy: number | null; msp_yoy: number | null; mos_latest: number | null; mos_prior: number | null;
+ };
+ const raws = new Map<number, Raw>();
+ for (const [id, o] of regions) {
+ const g = (k: string) => (k in o.m && Number.isFinite(o.m[k]) ? o.m[k] : null);
+ const zhviYoy = g('zhvi_yoy'), mspYoy = g('median_sale_price_yoy');
+ const mosL = g('months_of_supply'), mosP = g('months_of_supply_prior');
+ raws.set(id, {
+ momentum_raw: zhviYoy ?? mspYoy,
+ affordability_raw: g('affordability'),
+ rent_yield_raw: g('rent_yield'),
+ mos_change: mosL != null && mosP != null && mosP > 0 ? (mosL - mosP) / mosP : null,
+ dom: g('dom'), sale_to_list: g('sale_to_list'),
+ zhvi_yoy: zhviYoy, msp_yoy: mspYoy, mos_latest: mosL, mos_prior: mosP,
+ });
+ }
+
+ const pool_ = (pick: (r: Raw) => number | null) =>
+ [...raws.values()].map(pick).filter((v): v is number => v != null);
+ const pMomentum = percentiler(pool_(r => r.momentum_raw));
+ const pAfford = percentiler(pool_(r => r.affordability_raw));
+ const pYield = percentiler(pool_(r => r.rent_yield_raw));
+ const pMos = percentiler(pool_(r => r.mos_change));
+ const pDom = percentiler(pool_(r => r.dom));
+ const pStl = percentiler(pool_(r => r.sale_to_list));
+
+ const results: { regionId: number; score: number; components: any }[] = [];
+ for (const [id] of regions) {
+ const r = raws.get(id)!;
+ const sub: Record<string, number | null> = {
+ momentum: r.momentum_raw != null ? round6(pMomentum(r.momentum_raw)) : null,
+ affordability: r.affordability_raw != null ? round6(1 - pAfford(r.affordability_raw)) : null,
+ rent_yield: r.rent_yield_raw != null ? round6(pYield(r.rent_yield_raw)) : null,
+ inventory_trend: r.mos_change != null ? round6(1 - pMos(r.mos_change)) : null,
+ velocity: null,
+ };
+ const velParts: number[] = [];
+ if (r.dom != null) velParts.push(1 - pDom(r.dom));
+ if (r.sale_to_list != null) velParts.push(pStl(r.sale_to_list));
+ if (velParts.length) sub.velocity = round6(velParts.reduce((a, b) => a + b, 0) / velParts.length);
+
+ const present = COMPONENTS.filter(c => sub[c] != null);
+ if (!present.length) continue;
+ const wSum = present.reduce((a, c) => a + WEIGHTS[c], 0);
+ const weightsUsed: Record<string, number> = {};
+ for (const c of present) weightsUsed[c] = round6(WEIGHTS[c] / wSum);
+ const score = Math.min(100, Math.max(0,
+ present.reduce((a, c) => a + weightsUsed[c] * (sub[c] as number), 0) * 100));
+
+ results.push({
+ regionId: id,
+ score: Math.round(score * 100) / 100,
+ components: {
+ sub_scores: sub,
+ weights_used: weightsUsed,
+ data_completeness: round6(present.length / COMPONENTS.length),
+ raw: {
+ zhvi_yoy: r.zhvi_yoy, median_sale_price_yoy: r.msp_yoy,
+ affordability: r.affordability_raw, rent_yield: r.rent_yield_raw,
+ months_of_supply_latest: r.mos_latest, months_of_supply_prior: r.mos_prior,
+ months_of_supply_change_12mo: r.mos_change != null ? round6(r.mos_change) : null,
+ dom: r.dom, sale_to_list: r.sale_to_list,
+ },
+ },
+ });
+ }
+
+ // dense rank by score desc within region_type
+ results.sort((a, b) => b.score - a.score || a.regionId - b.regionId);
+ let rank = 0, prevScore: number | null = null;
+ for (const res of results) {
+ if (res.score !== prevScore) { rank++; prevScore = res.score; }
+ (res as any).rank = rank;
+ }
+
+ for (let i = 0; i < results.length; i += 500) {
+ const batch = results.slice(i, i + 500);
+ const params: unknown[] = [];
+ const values = batch.map((res: any, j) => {
+ params.push(res.regionId, scoreDate, res.score, JSON.stringify(res.components), res.rank);
+ const b = j * 5;
+ return `($${b + 1},$${b + 2}::date,$${b + 3},$${b + 4}::jsonb,$${b + 5})`;
+ });
+ await query(
+ `INSERT INTO region_scores (region_id, score_date, opportunity_score, components, rank)
+ VALUES ${values.join(',')}
+ ON CONFLICT (region_id, score_date)
+ DO UPDATE SET opportunity_score = EXCLUDED.opportunity_score,
+ components = EXCLUDED.components, rank = EXCLUDED.rank`,
+ params,
+ );
+ }
+ totalUpserts += results.length;
+ console.log(`[score] ${regionType}: ${results.length} scored`);
+ }
+ console.log(`[score] ok: ${totalUpserts} region_scores rows for ${scoreDate}`);
+ await pool.end();
+}
+
+main().catch(e => { console.error('[score] FAILED:', e); process.exit(1); });
← aae13b5 M3: ACS 2023 5-yr county demographics (19.3k rows), FHFA HPI
·
back to Nationalrealestate
·
CCK review fixes: legacy CT counties seeded (CT choropleth h c188ec3 →