← back to Commercialrealestate
auto-save: 2026-06-29T07:19:26 (7 files) — 5x/deals-assert.js 5x/index-assert.js 5x/map-assert.js public/deals.html public/index.html
311558af34169724c655c372a23f9c3bbc48a069 · 2026-06-29 07:19:32 -0700 · Steve Abrams
Files touched
M 5x/deals-assert.jsM 5x/index-assert.jsM 5x/map-assert.jsA public/deal-score.jsM public/deals.htmlM public/index.htmlM public/map.html
Diff
commit 311558af34169724c655c372a23f9c3bbc48a069
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 29 07:19:32 2026 -0700
auto-save: 2026-06-29T07:19:26 (7 files) — 5x/deals-assert.js 5x/index-assert.js 5x/map-assert.js public/deals.html public/index.html
---
5x/deals-assert.js | 18 +++++++--
5x/index-assert.js | 31 ++++++++++++--
5x/map-assert.js | 16 +++++++-
public/deal-score.js | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++
public/deals.html | 58 +++++++++++++++++++++++++-
public/index.html | 44 +++++++++++++++++++-
public/map.html | 66 ++++++++++++++++++++++++++++--
7 files changed, 329 insertions(+), 16 deletions(-)
diff --git a/5x/deals-assert.js b/5x/deals-assert.js
index ae316fc..f8efca5 100644
--- a/5x/deals-assert.js
+++ b/5x/deals-assert.js
@@ -15,6 +15,7 @@ async function run(label, mockEmpty){
await pg.route('**/api/crcp/stats', r=>r.fulfill({status:200,contentType:'application/json',body:'{}'}));
await pg.route('**/data/ranked.json', r=>r.fulfill({status:200,contentType:'application/json',body:'{"ranked":[]}'}));
await pg.route('**/data/map-points.json', r=>r.fulfill({status:200,contentType:'application/json',body:'{"points":[]}'}));
+ await pg.route('**/data/demographics.json', r=>r.fulfill({status:200,contentType:'application/json',body:'{"byCity":{}}'}));
}
await pg.goto('http://127.0.0.1:9911/deals.html', { waitUntil:'networkidle' });
await pg.waitForTimeout(2200);
@@ -22,7 +23,15 @@ async function run(label, mockEmpty){
const charts = CHARTS.map(id=>{ const c=Chart.getChart(id); return { id, exists:!!c, points: c?(c.data.datasets[0]?.data?.length||0):0 }; });
const tiles = [...document.querySelectorAll('.tile .v')].map(v=>v.textContent);
const nanTiles = tiles.filter(t=>/nan|undefined/i.test(t));
- return { charts, tiles, nanTiles };
+ // Top Deals by Unified Score: rows render, each score is a finite 0-100, in descending order, no NaN.
+ const tdRows = [...document.querySelectorAll('#tdlist .tdrow')];
+ const tdScores = tdRows.map(r=>{ const m=(r.querySelector('.sc')?.textContent||'').match(/(\d+)/); return m?+m[1]:null; }).filter(s=>s!=null);
+ let tdDesc=true; for(let i=1;i<tdScores.length;i++) if(tdScores[i]>tdScores[i-1]) tdDesc=false;
+ const td = { rows:tdRows.length, scores:tdScores.length, descending:tdDesc,
+ allFinite:tdScores.every(s=>isFinite(s)&&s>=0&&s<=100),
+ anyNaN:/NaN|\$NaN|undefined/.test(document.querySelector('#topdeals')?.textContent||''),
+ panelPresent:!!document.querySelector('#topdeals') };
+ return { charts, tiles, nanTiles, td };
}, CHARTS);
await b.close();
const chartsWithData = r.charts.filter(c=>c.points>0).length;
@@ -33,8 +42,11 @@ async function run(label, mockEmpty){
const normal = await run('NORMAL', false);
const empty = await run('EMPTY-DATA', true);
// Pass criteria:
- const passNormal = normal.charts.every(c=>c.exists) && normal.chartsWithData>=5 && normal.nanTiles.length===0 && normal.jsErrors.length===0;
- const passEmpty = empty.nanTiles.length===0 && empty.jsErrors.length===0; // empty allowed; NaN/crash NOT
+ const passNormal = normal.charts.every(c=>c.exists) && normal.chartsWithData>=5 && normal.nanTiles.length===0
+ && normal.td.panelPresent && normal.td.rows>0 && normal.td.scores>1 && normal.td.allFinite && normal.td.descending && !normal.td.anyNaN
+ && normal.jsErrors.length===0;
+ // empty: top-deals panel must degrade to 0 rows (or a "no scorable deals" note) with NO NaN, no crash.
+ const passEmpty = empty.nanTiles.length===0 && !empty.td.anyNaN && empty.td.panelPresent && empty.jsErrors.length===0;
console.log(JSON.stringify({ normal, empty, passNormal, passEmpty, VERDICT: (passNormal&&passEmpty)?'PASS':'FAIL' }, null, 2));
process.exit((passNormal&&passEmpty)?0:1);
})().catch(e=>{ console.error('FAIL', e.message); process.exit(1); });
diff --git a/5x/index-assert.js b/5x/index-assert.js
index 8bad0a9..cd8c01b 100644
--- a/5x/index-assert.js
+++ b/5x/index-assert.js
@@ -20,25 +20,48 @@ async function run(mockEmpty){
anyNaN:/NaN|\$NaN|undefined/.test([...document.querySelectorAll('.grid .card')].slice(0,40).map(c=>c.textContent).join(' ')),
loopnet:document.querySelectorAll('.src a[href*="loopnet"], .src a[href*="site:loopnet"]').length,
rentLines:[...document.querySelectorAll('.card')].filter(c=>/Est\. rent/.test(c.textContent)).length,
+ // Deal Score: badges render, every shown score is a finite 0-100, scores compute on the row,
+ // and the breakdown tooltip explains the sub-scores (transparent, not a black box).
+ dealBadges:document.querySelectorAll('.deal-badge').length,
+ dealScoresOk:(()=>{ const ds=(DATA&&DATA.ranked||[]).map(p=>p.deal).filter(d=>d&&d.score!=null);
+ return { scored:ds.length, allFinite:ds.every(d=>isFinite(d.score)&&d.score>=0&&d.score<=100),
+ hasParts:ds.every(d=>Array.isArray(d.parts)&&d.parts.length>0), anyNaN:ds.some(d=>!isFinite(d.score)) }; })(),
+ dealBadgeNaN:[...document.querySelectorAll('.deal-badge')].some(b=>/NaN|undefined/.test(b.textContent)),
+ dealTipOk:/Deal Score \d+\/100/.test(document.querySelector('.deal-badge')?.getAttribute('title')||''),
countTxt:(document.querySelector('.bar .count')?.textContent||'').replace(/\s+/g,' ').trim() }));
- let sortOk=null, filterOk=null;
+ let sortOk=null, filterOk=null, dealSortOk=null;
if(!mockEmpty){
sortOk=await pg.evaluate(async()=>{ const sel=document.querySelector('#sort'); if(!sel||sel.options.length<2) return null;
const first=()=>document.querySelector('.grid .card .addr')?.textContent||document.querySelector('.grid .card')?.textContent.slice(0,40);
- const before=first(); sel.selectedIndex=(sel.selectedIndex+1)%sel.options.length; sel.dispatchEvent(new Event('change'));
+ const before=first();
+ // pick a sort guaranteed to reorder (Price ↑) rather than the next index — robust to option order.
+ sel.value=[...sel.options].some(o=>o.value==='priceAsc')?'priceAsc':sel.options[(sel.selectedIndex+1)%sel.options.length].value;
+ sel.dispatchEvent(new Event('change'));
await new Promise(r=>setTimeout(r,500)); return { before, after:first(), changed:before!==first() }; });
filterOk=await pg.evaluate(async()=>{ const chip=document.querySelector('.chip'); if(!chip) return null;
const n=()=>document.querySelectorAll('.grid .card').length; const before=n();
chip.click(); await new Promise(r=>setTimeout(r,500)); const after=n();
return { before, after, narrowed: after<before && after>=0 }; });
+ // "Deal Score ↓" sort: selecting it orders the visible cards by descending score (first >= second).
+ dealSortOk=await pg.evaluate(async()=>{ const sel=document.querySelector('#sort'); if(!sel) return null;
+ sel.value='deal'; sel.dispatchEvent(new Event('change')); await new Promise(r=>setTimeout(r,500));
+ const score=c=>{ const m=(c.querySelector('.deal-badge')?.textContent||'').match(/Deal (\d+)/); return m?+m[1]:null; };
+ const cards=[...document.querySelectorAll('.grid .card')].slice(0,30);
+ const scs=cards.map(score).filter(s=>s!=null);
+ let desc=true; for(let i=1;i<scs.length;i++) if(scs[i]>scs[i-1]) desc=false;
+ return { sampled:scs.length, first:scs[0], descending:desc, optionExists:[...sel.options].some(o=>o.value==='deal') }; });
}
await b.close();
- return { mockEmpty, ...base, sortOk, filterOk, jsErrors:errors };
+ return { mockEmpty, ...base, sortOk, filterOk, dealSortOk, jsErrors:errors };
}
(async()=>{
const normal=await run(false); const empty=await run(true);
const passNormal = normal.cards>0 && !normal.anyNaN && normal.loopnet>0 && normal.rentLines>0
- && (!normal.sortOk||normal.sortOk.changed) && (!normal.filterOk||normal.filterOk.narrowed) && normal.jsErrors.length===0;
+ && (!normal.sortOk||normal.sortOk.changed) && (!normal.filterOk||normal.filterOk.narrowed)
+ && normal.dealBadges>0 && !normal.dealBadgeNaN && normal.dealTipOk
+ && normal.dealScoresOk.scored>0 && normal.dealScoresOk.allFinite && normal.dealScoresOk.hasParts && !normal.dealScoresOk.anyNaN
+ && normal.dealSortOk && normal.dealSortOk.optionExists && normal.dealSortOk.descending && normal.dealSortOk.sampled>1
+ && normal.jsErrors.length===0;
const passEmpty = empty.cards===0 && !empty.anyNaN && empty.jsErrors.length===0;
console.log(JSON.stringify({normal,empty,passNormal,passEmpty,VERDICT:(passNormal&&passEmpty)?'PASS':'FAIL'},null,2));
process.exit((passNormal&&passEmpty)?0:1);
diff --git a/5x/map-assert.js b/5x/map-assert.js
index fe0dfab..785813a 100644
--- a/5x/map-assert.js
+++ b/5x/map-assert.js
@@ -13,7 +13,7 @@ async function run(label, mockEmpty){
layerTypes:Object.keys(layers||{}).length,
markers:Object.values(layers||{}).reduce((n,l)=>n+l.getLayers().length,0),
clusters:document.querySelectorAll('.clusterdot').length}));
- let toggleOk=true, popup={}, priceOk=null;
+ let toggleOk=true, popup={}, priceOk=null, scoreOk=null;
if(!mockEmpty){
const labs=await pg.$$('#legend label');
for(const lab of labs){ const t=await lab.evaluate(e=>e.querySelector('input').dataset.t);
@@ -25,6 +25,16 @@ async function run(label, mockEmpty){
const typeColor = ALL[0] ? ALL[0].m.options.fillColor : null; btn.click(); await new Promise(r=>setTimeout(r,400));
return { mode:MODE, keyShown:document.querySelector('#pricekey').classList.contains('show'),
recolored: ALL[0] ? ALL[0].m.options.fillColor!==typeColor || true : false }; });
+ // color-by Score: recolors markers, shows the score-tier key, listing markers carry finite 0-100 scores, no NaN.
+ scoreOk=await pg.evaluate(async()=>{ const btn=document.querySelector('#colorby button[data-m=score]'); if(!btn) return null;
+ const before = ALL[0] ? ALL[0].m.options.fillColor : null; btn.click(); await new Promise(r=>setTimeout(r,600));
+ const lst=ALL.filter(x=>x.p.kind==='listing'); const scored=lst.filter(x=>scoreFor(x.p)!=null);
+ const anyNaN=ALL.slice(0,500).some(x=>{ const s=scoreFor(x.p); return s!=null && !isFinite(s); });
+ const allInRange=scored.every(x=>{ const s=scoreFor(x.p); return s>=0 && s<=100; });
+ const recolored=ALL.some(x=>x.m.options.fillColor!==before) || true;
+ return { mode:MODE, keyShown:document.querySelector('#scorekey').classList.contains('show'),
+ listings:lst.length, scored:scored.length, anyNaN, allInRange, recolored,
+ keyHasTiers:document.querySelectorAll('#scorekey .pr').length>=5 }; });
// popup: zoom to a marker first (un-cluster), then open + decode aerial
popup=await pg.evaluate(async()=>{ let tgt=null; for(const t in layers){ layers[t].eachLayer(l=>{ if(!tgt)tgt=l; }); if(tgt)break; }
if(!tgt) return {open:false}; map.setView(tgt.getLatLng(),17); await new Promise(r=>setTimeout(r,700));
@@ -34,12 +44,14 @@ async function run(label, mockEmpty){
popup.decoded=await pg.evaluate(()=>{ const i=document.querySelector('.leaflet-popup img'); return i.complete&&i.naturalWidth>0; }); }
}
await b.close();
- return {label, ...base, toggleOk, priceOk, popup, jsErrors:errors};
+ return {label, ...base, toggleOk, priceOk, scoreOk, popup, jsErrors:errors};
}
(async()=>{
const normal=await run('NORMAL',false); const empty=await run('EMPTY',true);
const passNormal = normal.markers>1000 && normal.legend>=4 && normal.clusters>0 && normal.toggleOk
&& normal.priceOk && normal.priceOk.mode==='price' && normal.priceOk.keyShown
+ && normal.scoreOk && normal.scoreOk.mode==='score' && normal.scoreOk.keyShown && normal.scoreOk.keyHasTiers
+ && normal.scoreOk.scored>100 && !normal.scoreOk.anyNaN && normal.scoreOk.allInRange
&& normal.popup.open && normal.popup.decoded && normal.jsErrors.length===0;
const passEmpty = empty.markers===0 && empty.jsErrors.length===0;
console.log(JSON.stringify({normal,empty,passNormal,passEmpty,VERDICT:(passNormal&&passEmpty)?'PASS':'FAIL'},null,2));
diff --git a/public/deal-score.js b/public/deal-score.js
new file mode 100644
index 0000000..459d94f
--- /dev/null
+++ b/public/deal-score.js
@@ -0,0 +1,112 @@
+// deal-score.js — transparent, weight-normalized 0-100 Deal Score for the LA County CRE screener.
+// Works in Node (require) and the browser (global DealScore). SINGLE SOURCE OF TRUTH so the
+// property cards, the map color-by-score ramp, and the deals dashboard all rank identically.
+//
+// Philosophy: synthesize the signals we ALREADY computed elsewhere into one "chase-this-first" number.
+// - Every sub-score is a labeled 0-100 mapping of one real signal.
+// - dealScore = Σ(sub × weight) / Σ(weight) over ONLY the components that are present.
+// Missing signals drop out and the remaining weights re-normalize → never NaN, never a free ride.
+// - Bonuses (assumable, FHA-warrantable) are upside nudges with small weight, applied the same way.
+// - We are honest: proxy/low-confidence sub-scores are labeled as such in the breakdown so the UI
+// can show WHY a deal scored what it did. This is a screen, not an appraisal.
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.DealScore = factory();
+})(typeof self !== 'undefined' ? self : this, function () {
+
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
+ const fin = v => (typeof v === 'number' && isFinite(v)) ? v : null;
+ // linear map of x in [a,b] -> [0,100], clamped; guards a===b (div-by-zero) -> 50.
+ function ramp(x, a, b) { x = fin(x); if (x == null) return null; if (a === b) return 50; return clamp(100 * (x - a) / (b - a), 0, 100); }
+
+ // Component weights (relative; the formula re-normalizes over whatever is present).
+ const W = { rentRoi: 30, cap: 28, dscr: 22, assumable: 12, warrant: 8 };
+
+ // ---- sub-score mappings (each returns 0-100 or null when the signal is absent) ----
+
+ // Pro-forma gross rent yield. LA gross yields run ~5-10%; >18% is almost always a bad unit count.
+ // Clamp those outliers to low-confidence: cap the points at 60 AND halve the weight so a fishy
+ // listing can't top the board on a number we don't trust.
+ function rentRoiSub(grossYieldPro) {
+ const y = fin(grossYieldPro); if (y == null) return null;
+ if (y > 18) return { core: true, score: clamp(ramp(y, 4, 12), 0, 60), weight: W.rentRoi * 0.5, conf: 'low', label: 'Pro-forma rent yield (outlier — verify units)' };
+ return { core: true, score: ramp(y, 4, 12), weight: W.rentRoi, conf: 'proxy', label: 'Pro-forma rent yield' };
+ }
+
+ // Cap rate (commercial). 4% -> 0, 9% -> 100. Broker-stated unless verified.
+ function capSub(cap_rate, verified) {
+ const c = fin(cap_rate); if (c == null) return null;
+ return { core: true, score: ramp(c, 4, 9), weight: W.cap, conf: verified === true ? 'verified' : 'broker', label: verified === true ? 'Cap rate (verified)' : 'Cap rate (broker-stated)' };
+ }
+
+ // DSCR-readiness. Prefer the real leverage-math DSCR. When there's no in-place NOI but we have a
+ // rent estimate, derive a PROXY DSCR = est NOI vs the standard-loan debt service the caller passes.
+ // 1.0 -> 0, 1.5 -> 100 (1.25 = the bankable line ~ 50pts).
+ function dscrSub(dscr, proxyDscr) {
+ const real = fin(dscr);
+ if (real != null) return { core: true, score: ramp(real, 1.0, 1.5), weight: W.dscr, conf: 'derived', label: 'DSCR (from in-place cap)' };
+ const px = fin(proxyDscr);
+ if (px != null) return { core: true, score: ramp(px, 1.0, 1.5), weight: W.dscr * 0.7, conf: 'proxy', label: 'DSCR-readiness (rent-estimate proxy)' };
+ return null;
+ }
+
+ // Assumable FHA/VA bonus (heuristic, not title-verified). high > medium.
+ function assumableSub(assum) {
+ if (!assum || !assum.lk) return null;
+ const score = assum.lk === 'high' ? 100 : assum.lk === 'medium' ? 65 : null;
+ if (score == null) return null;
+ return { score, weight: W.assumable, conf: 'heuristic', label: 'Assumable FHA/VA (heuristic)' };
+ }
+
+ // FHA-warrantability bonus (condo only). Binary signal -> full credit when approved.
+ function warrantSub(fhaApproved) {
+ if (fhaApproved !== true) return null;
+ return { score: 100, weight: W.warrant, conf: 'signal', label: 'FHA-warrantable condo' };
+ }
+
+ // ---- main: assemble present components, weight-normalize, never NaN ----
+ // input: { grossYieldPro, cap_rate, verified, dscr, proxyDscr, assum, fhaApproved }
+ function compute(input) {
+ input = input || {};
+ const parts = [
+ rentRoiSub(input.grossYieldPro),
+ capSub(input.cap_rate, input.verified),
+ dscrSub(input.dscr, input.proxyDscr),
+ assumableSub(input.assum),
+ warrantSub(input.fhaApproved),
+ ].filter(p => p && fin(p.score) != null && fin(p.weight) != null && p.weight > 0);
+
+ if (!parts.length) return { score: null, parts: [], confidence: 'none' };
+
+ const wsum = parts.reduce((s, p) => s + p.weight, 0);
+ if (!(wsum > 0)) return { score: null, parts, confidence: 'none' };
+ const raw = parts.reduce((s, p) => s + p.score * p.weight, 0) / wsum;
+ const score = clamp(Math.round(raw), 0, 100);
+
+ // Confidence: how much of the FULL core weight budget is backed by present signals, and whether
+ // any core return signal (rent/cap/dscr) is present at all (bonuses alone = thin, untrustworthy).
+ const coreWeight = parts.filter(p => p.core).reduce((s, p) => s + p.weight, 0);
+ const coverage = coreWeight / (W.rentRoi + W.cap + W.dscr); // bonuses excluded from the denominator
+ const confidence = coreWeight <= 0 ? 'thin' : coverage >= 0.7 ? 'high' : coverage >= 0.35 ? 'med' : 'low';
+
+ return { score, parts, confidence, coverage: +coverage.toFixed(2) };
+ }
+
+ // Tier + color ramp for the map (sequential 0-100; distinct from the type + price palettes).
+ // 5 tiers so a legend key is readable. n/a -> grey.
+ const TIERS = [
+ { min: 80, c: '#16a34a', label: '80–100 · chase now' },
+ { min: 60, c: '#84cc16', label: '60–79 · strong' },
+ { min: 40, c: '#eab308', label: '40–59 · watch' },
+ { min: 20, c: '#f97316', label: '20–39 · weak' },
+ { min: 0, c: '#dc2626', label: '0–19 · pass' },
+ ];
+ const SCORE_NA = '#6b7280';
+ function scoreColor(score) {
+ const s = fin(score); if (s == null) return SCORE_NA;
+ for (const t of TIERS) if (s >= t.min) return t.c;
+ return SCORE_NA;
+ }
+
+ return { compute, scoreColor, TIERS, SCORE_NA, WEIGHTS: W, _ramp: ramp };
+});
diff --git a/public/deals.html b/public/deals.html
index 52c9306..d2693ae 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -6,6 +6,7 @@
<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='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>Deal Intelligence — LA County CRE</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+<script src="/deal-score.js"></script>
<style>
:root{
--gold:#e9c46a; --g2:#c8a24b; --violet:#8b7cf6; --teal:#2dd4bf; --blue:#60a5fa; --pink:#f472b6; --orange:#fb923c;
@@ -72,6 +73,21 @@
.seg-toggle{display:inline-flex;background:var(--glass);border:1px solid var(--glassb);border-radius:13px;overflow:hidden;backdrop-filter:blur(14px);}
.seg-toggle button{background:none;border:0;color:var(--muted);font-size:12px;padding:7px 12px;cursor:pointer;transition:.2s;}
.seg-toggle button.on{background:linear-gradient(90deg,rgba(233,196,106,.28),rgba(45,212,191,.2));color:#fff;}
+ /* ---- Top deals by score ---- */
+ .topdeals{margin-top:15px;padding:18px 19px 16px;}
+ .topdeals h3{font-size:13px;font-weight:700;display:flex;align-items:center;gap:8px;margin-bottom:3px;}
+ .topdeals .sub{font-size:11.5px;color:var(--muted);margin-bottom:12px;}
+ .tdlist{display:flex;flex-direction:column;gap:7px;}
+ .tdrow{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:12px;
+ padding:9px 12px;border-radius:13px;background:rgba(255,255,255,.04);border:1px solid var(--glassb);}
+ .tdrow .sc{font-size:19px;font-weight:800;font-variant-numeric:tabular-nums;width:46px;text-align:center;line-height:1;}
+ .tdrow .sc small{display:block;font-size:8.5px;font-weight:600;color:var(--muted);letter-spacing:.4px;}
+ .tdrow .ad{font-size:13px;font-weight:600;}
+ .tdrow .ad .cy{color:var(--muted);font-weight:400;font-size:11.5px;}
+ .tdrow .mt{font-size:11px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums;}
+ .tdrow .pr{font-size:13px;font-weight:700;color:var(--gold);text-align:right;font-variant-numeric:tabular-nums;}
+ .tdrow a{color:inherit;text-decoration:none;}
+ @media(max-width:620px){.tdrow{grid-template-columns:auto 1fr auto;}.tdrow .mt{display:none;}}
/* a11y: respect reduced-motion — kill the drifting gradient + card rise for motion-sensitive users */
@media(prefers-reduced-motion:reduce){
body::before{animation:none;}
@@ -118,6 +134,12 @@
<div class="sub">p10–median–p90 range — the deal-size spread</div><div class="cw"><canvas id="cBand"></canvas></div></div>
</div>
+ <div class="glass topdeals" id="topdeals" style="animation-delay:.35s">
+ <h3><span class="dot" style="color:var(--gold)"></span>Top Deals by Unified Score</h3>
+ <div class="sub">Highest 0–100 Deal Scores — blend of pro-forma rent yield + cap rate + DSCR-readiness + assumable bonus, re-normalized over present signals. A screen, not an appraisal.</div>
+ <div class="tdlist" id="tdlist"></div>
+ </div>
+
<div class="foot">
<span class="pill">Liquid-glass UI · 2026</span><span class="pill">Chart.js</span><span class="pill">live data</span><br>
Estimates labeled where used (assumable = heuristic; price bands = p10/p90; rent/ROI = Census proxy). Not an appraisal.
@@ -148,10 +170,11 @@ function pull(){
fetch('/api/crcp/stats').then(r=>r.json()).catch(()=>({})),
fetch('/data/ranked.json').then(r=>r.json()).catch(()=>({ranked:[]})),
fetch('/data/map-points.json').then(r=>r.json()).catch(()=>({points:[]})),
+ fetch('/data/demographics.json').then(r=>r.json()).catch(()=>({byCity:{}})),
]).then(d=>{ LAST=d; draw(); }).catch(e=>{ document.getElementById('asof').textContent='data load error: '+e.message; });
}
function draw(){
- const [seg, stats, ranked, mapd]=LAST;
+ const [seg, stats, ranked, mapd, demo]=LAST;
['cPipe','cType','cCap','cMed','cGeo','cBand'].forEach(id=>{ const c=Chart.getChart(id); if(c)c.destroy(); }); // no double-init on redraw
const segs=(seg.segments||[]).map(s=>({...s,props:+s.props||0,agents:+s.agents||0,med:+s.priceMed||0,lo:+s.priceMin||0,hi:+s.priceMax||0}))
.filter(s=>s.props>0).sort((a,b)=>b.props-a.props);
@@ -221,8 +244,41 @@ function draw(){
callbacks:{label:c=>{const s=sb[c.dataIndex];return [' p10 '+F.money(s.lo),' med '+F.money(s.med),' p90 '+F.money(s.hi)];}}}},
scales:{x:{grid:{color:'rgba(255,255,255,.06)'},ticks:{callback:v=>F.money(v)}},y:{grid:{display:false},ticks:{font:{size:10}}}},animation:{duration:1100}}});
+ // ---- Top deals by Unified Score (same DealScore.compute as cards + map) ----
+ renderTopDeals(rows, (demo&&demo.byCity)||{});
+
document.getElementById('asof').textContent=`LA County pipeline · ${F.num(screened)} properties · ${segs.length} loan products · updated just now`;
}
+
+const TDSCEN={downPct:25,ratePct:6.75,amortYears:30};
+function tdDebtSvc(loan){ if(!(loan>0)) return 0; const r=TDSCEN.ratePct/100/12, n=TDSCEN.amortYears*12;
+ return r===0 ? loan/TDSCEN.amortYears : loan*(r*Math.pow(1+r,n))/(Math.pow(1+r,n)-1)*12; }
+function scoreRow(p, DEMO){
+ const d=DEMO[(p.city||'').toLowerCase()];
+ let gy=null, proYr=null;
+ if(d&&d.median_gross_rent&&p.price>0){ const pro=Math.round(d.median_gross_rent*(p.units||1)*1.12); proYr=pro*12; gy=+(100*proYr/p.price).toFixed(2); }
+ let dscr=p.finance?p.finance.dscr:null, px=null;
+ if(p.cap_rate==null&&proYr&&p.price>0){ const ads=tdDebtSvc(p.price*(1-TDSCEN.downPct/100)); if(ads>0) px=+((proYr*0.6)/ads).toFixed(2); }
+ return DealScore.compute({grossYieldPro:gy,cap_rate:p.cap_rate,verified:p.verified,dscr,proxyDscr:px,assum:p.assum,fhaApproved:p.fhaApproved});
+}
+function renderTopDeals(rows, DEMO){
+ const scored=rows.map(p=>({p,d:scoreRow(p,DEMO)})).filter(x=>x.d&&x.d.score!=null)
+ .sort((a,b)=>b.d.score-a.d.score).slice(0,12);
+ const el=document.getElementById('tdlist');
+ if(!scored.length){ el.innerHTML='<div style="color:var(--muted);font-size:12px;padding:8px">No scorable deals in the current dataset.</div>'; return; }
+ el.innerHTML=scored.map(({p,d})=>{
+ const col=DealScore.scoreColor(d.score);
+ const cap=p.cap_rate!=null?`${p.cap_rate}% cap`:'cap n/d';
+ const dscr=p.finance&&p.finance.dscr!=null?` · ${p.finance.dscr} DSCR`:'';
+ const conf=(d.confidence==='low'||d.confidence==='thin')?' ?':'';
+ const tip=d.parts.map(pt=>`${pt.label}: ${Math.round(pt.score)}/100 (${pt.conf})`).join(' · ');
+ return `<div class="tdrow" title="${tip.replace(/"/g,'"')}">`+
+ `<div class="sc" style="color:${col}">${d.score}${conf}<small>SCORE</small></div>`+
+ `<div class="ad"><a href="/index.html" title="open in the property explorer">${p.address||'(addr n/a)'}</a> <span class="cy">${p.city||''}</span></div>`+
+ `<div class="mt">${p.type||''} · ${p.units||'?'}u<br>${cap}${dscr}</div>`+
+ `<div class="pr">${F.money(p.price)}</div></div>`;
+ }).join('');
+}
// ---- controls (real, auto-discoverable) ----
document.getElementById('refresh').addEventListener('click', pull);
document.getElementById('metric').addEventListener('click', e=>{
diff --git a/public/index.html b/public/index.html
index 6d8c6a6..58692dc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,6 +3,7 @@
<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='%230e1116'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%233fb950'%3E%24%3C/text%3E%3C/svg%3E">
<title>LA County CRE — Investment Explorer</title>
<style>
:root { --cols:3; --bg:#0e1116; --card:#161b22; --line:#2a313c; --ink:#e6edf3; --mut:#8b949e; --acc:#3fb950; --warn:#d29922; --bad:#f85149; --blue:#58a6ff; --drawerW:316px; }
@@ -71,6 +72,7 @@
.badges { display:flex; flex-wrap:wrap; gap:6px; }
.b { font-size:11px; padding:3px 8px; border-radius:20px; border:1px solid var(--line); color:var(--mut); }
.b.type { color:var(--blue); border-color:#284b7a; }
+ .b.deal-badge { font-weight:700; cursor:help; }
.b.ok { color:var(--acc); border-color:#1f6f37; }
.b.warn { color:var(--warn); border-color:#6b5320; }
.b.bad { color:var(--bad); border-color:#7d2b28; }
@@ -166,6 +168,7 @@
</div>
<div class="fsec"><h4>Sort & density</h4>
<select id="sort">
+ <option value="deal">Deal Score ↓</option>
<option value="composite">Best Investment (blended)</option>
<option value="coc">Cash-on-Cash ↓</option>
<option value="yield">Rent Yield (rent÷price) ↓</option>
@@ -207,6 +210,7 @@
</div>
<script src="finance.js"></script>
+<script src="deal-score.js"></script>
<script>
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
@@ -247,6 +251,32 @@ function rentLine(p){
}
// For ranking, treat implausible (>18%) pro-forma yields as low-confidence so bad-units outliers don't top the list.
function rentRoiScore(p){ const r=rentEstimate(p); if(!r||!r.grossYieldPro) return -1e9; return r.grossYieldPro>18 ? -1 : r.grossYieldPro; }
+
+// ---- Unified Deal Score (0-100) — DealScore.compute() over the signals we already have. ----
+// Gathers: pro-forma gross yield (Census rent estimate), cap rate, in-place DSCR (finance.js),
+// a PROXY DSCR when no cap is disclosed (est NOI from pro-forma rent vs the active-scenario debt
+// service), and the assumable-FHA/VA bonus. Transparent + labeled; never NaN. Cached on the row.
+function dealScore(p){
+ const r = rentEstimate(p);
+ const grossYieldPro = (r && r.grossYieldPro != null && isFinite(r.grossYieldPro)) ? r.grossYieldPro : null;
+ // Proxy DSCR only when there is NO in-place cap (else the real DSCR carries it). Estimate in-place
+ // NOI as pro-forma annual gross × 0.6 (a 40% commercial expense ratio) and compare to debt service.
+ let proxyDscr = null;
+ const f = p.finance;
+ if (p.cap_rate == null && r && r.pro && f && f.annualDebtService > 0){
+ const estNoi = r.pro * 12 * 0.6;
+ proxyDscr = +(estNoi / f.annualDebtService).toFixed(2);
+ }
+ return DealScore.compute({
+ grossYieldPro,
+ cap_rate: p.cap_rate,
+ verified: p.verified,
+ dscr: f ? f.dscr : null,
+ proxyDscr,
+ assum: p.assum, // present only if the geocode pipeline tagged this listing (heuristic)
+ fhaApproved: p.fhaApproved
+ });
+}
// LoopNet cross-reference for EVERY listing — free deep-links ($0, no scrape). Mirrors scripts/sources/loopnet.js.
const LN_TYPE={'Multifamily':'multifamily','Office':'office','Retail':'retail','Industrial':'industrial','Mixed-use':'mixed-use','Mixed Use':'mixed-use','Land':'land'};
function loopnetLink(p){
@@ -269,6 +299,7 @@ function recompute(){
const fsc = CREFin.financeScore(p, p.finance);
p.financeScore = fsc.score; p.financeConf = fsc.conf;
p.composite = CREFin.composite(p, p.finance, fsc.score, p.qwenScore);
+ p.deal = dealScore(p); // {score, parts, confidence, coverage} — cached for badge + sort + tooltip
});
DATA.ranked.sort((a,b)=>b.composite-a.composite);
DATA.ranked.forEach((p,i)=>p.rank=i+1);
@@ -284,6 +315,14 @@ function firmLabel(p){
return d ? d.split('.')[0].replace(/^./,c=>c.toUpperCase()) : 'Unknown';
}
+// Deal-Score badge + plain-text breakdown tooltip (explains WHY the score is what it is).
+function dealBadge(p){
+ const d = p.deal; if(!d || d.score==null) return `<span class="b" title="Not enough signal to score this deal (no cap rate, rent estimate, or DSCR available).">⬡ Deal —</span>`;
+ const col = DealScore.scoreColor(d.score);
+ const lines = d.parts.map(pt=>`• ${pt.label}: ${Math.round(pt.score)}/100 (${pt.conf}, weight ${Math.round(pt.weight)})`).join('\n');
+ const tip = `Deal Score ${d.score}/100 — weighted blend of the signals below, re-normalized over what's present (missing signals drop out, never NaN).\nConfidence: ${d.confidence}${d.coverage!=null?` (core coverage ${Math.round(d.coverage*100)}%)`:''}.\n\n${lines}\n\nA screen, not an appraisal. Verify NOI + units in DD.`;
+ return `<span class="b deal-badge" style="color:${col};border-color:${col}" title="${tip.replace(/"/g,'"')}">⬡ Deal ${d.score}${d.confidence==='low'||d.confidence==='thin'?' ?':''}</span>`;
+}
function card(p){
const f=p.finance, uc=/Under Contract|Pending/i.test(p.status);
const firmB = `<span class="b" title="Listing source / brokerage firm">🏢 ${firmLabel(p)}</span>`;
@@ -302,7 +341,7 @@ function card(p){
<div><div class="rank">#${p.rank} · score</div><div class="addr">${p.address}</div><div class="city">${p.city}, CA ${p.zip||''}</div></div>
<div class="score">${p.composite}<small>fin ${p.financeScore}${p.qwenScore!=null?' · ai '+p.qwenScore:''}</small></div>
</div>
- <div class="badges">${firmB}<span class="b type">${p.type}</span><span class="b">${p.units} unit${p.units>1?'s':''}</span><span class="b">${fmt(p.price)}</span>${capB}${verB}${f.grossYield?`<span class="b ok" title="Annual gross rent ÷ price">${f.grossYield}% rent yld</span>`:''}${statusB} ${aff} ${rec}</div>
+ <div class="badges">${dealBadge(p)}${firmB}<span class="b type">${p.type}</span><span class="b">${p.units} unit${p.units>1?'s':''}</span><span class="b">${fmt(p.price)}</span>${capB}${verB}${f.grossYield?`<span class="b ok" title="Annual gross rent ÷ price">${f.grossYield}% rent yld</span>`:''}${statusB} ${aff} ${rec}</div>
<div class="metrics">
<div><span class="k">Cash-on-cash</span><span class="v ${cls(f.coc,6,3)}">${pct(f.coc)}</span></div>
<div><span class="k">DSCR</span><span class="v ${cls(f.dscr,1.25,1.0)}">${f.dscr??'—'}</span></div>
@@ -347,13 +386,14 @@ function activeFilterCount(){
return n;
}
-const SORTS={ composite:(a,b)=>b.composite-a.composite, coc:(a,b)=>num(b.finance.coc)-num(a.finance.coc),
+const SORTS={ deal:(a,b)=>dealNum(b)-dealNum(a), composite:(a,b)=>b.composite-a.composite, coc:(a,b)=>num(b.finance.coc)-num(a.finance.coc),
yield:(a,b)=>num(b.finance.grossYield)-num(a.finance.grossYield),
cap:(a,b)=>num(b.cap_rate)-num(a.cap_rate), dscr:(a,b)=>num(b.finance.dscr)-num(a.finance.dscr),
priceAsc:(a,b)=>a.price-b.price, priceDesc:(a,b)=>b.price-a.price,
cashNeeded:(a,b)=>a.finance.cashNeeded-b.finance.cashNeeded, unitsDesc:(a,b)=>num(b.units)-num(a.units),
rentRoi:(a,b)=>rentRoiScore(b)-rentRoiScore(a) };
const num=v=>v==null?-1e9:v;
+const dealNum=p=>(p.deal && p.deal.score!=null) ? p.deal.score : -1e9; // unscored sink to the bottom
function render(){
let r = applyFilters(DATA.ranked.slice());
diff --git a/public/map.html b/public/map.html
index 231734f..ede9a17 100644
--- a/public/map.html
+++ b/public/map.html
@@ -53,14 +53,16 @@
</div>
<div id="map"></div>
<div class="legend">
- <div class="colorby" id="colorby"><button data-m="type" class="on">Color: Type</button><button data-m="price">Price</button></div>
+ <div class="colorby" id="colorby"><button data-m="type" class="on">Color: Type</button><button data-m="price">Price</button><button data-m="score">Score</button></div>
<h4>Property type <span style="font-weight:400;text-transform:none;font-size:10px">(click to filter)</span></h4>
<div id="legend"></div>
<div class="pricekey" id="pricekey"><h4>Price tier</h4></div>
+ <div class="pricekey" id="scorekey"><h4>Deal Score</h4></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
+<script src="/deal-score.js"></script>
<script>
// Free stack: Leaflet + OpenStreetMap (streets) + Esri World Imagery (satellite/aerial), US Census geocoding upstream.
const COLORS = {
@@ -78,9 +80,26 @@ L.control.layers({ 'Aerial (Esri)':sat, 'Streets (OSM)':street }, null, { positi
const layers = {}; // type -> L.markerClusterGroup (name kept for legend + gate compat)
const counts = {};
const ALL = []; // {m, p} — every marker + its point, for recoloring
-let MODE = 'type'; // 'type' | 'price'
+let MODE = 'type'; // 'type' | 'price' | 'score'
const money = n => n==null ? '' : '$'+(n>=1e6 ? (n/1e6).toFixed(n>=1e7?0:2)+'M' : Math.round(n/1e3)+'k');
+// Deal Score per point (0-100). Commercial listings join to ranked.json by id (listing-<rid> -> <rid>)
+// for cap/DSCR; everything carries its assumable bonus. Computed once into p._score (number|null).
+const SCORE_BY_RANKED = {}; // ranked id -> deal score (filled when ranked.json lands)
+function scoreFor(p){
+ if (p._score !== undefined) return p._score; // cached (incl. null)
+ let s = null;
+ if (p.kind === 'listing' && p.id){
+ const rid = String(p.id).replace(/^listing-/, '');
+ if (SCORE_BY_RANKED[rid] !== undefined) s = SCORE_BY_RANKED[rid];
+ }
+ if (s == null && p.assum){ // condos/SFR: assumable-only score (honest: no cap data on these)
+ const d = DealScore.compute({ assum: p.assum });
+ s = (d && d.score != null) ? d.score : null;
+ }
+ p._score = s; return s;
+}
+
// ---- price-tier coloring (sequential, distinct from the type palette) ----
const PRICE_TIERS = [
{ max:500000, c:'#2dd4bf', label:'< $500k' },
@@ -92,7 +111,11 @@ const PRICE_TIERS = [
const PRICE_NA = '#6b7280';
function priceColor(price){ if(price==null||!isFinite(price)||price<=0) return PRICE_NA;
for(const t of PRICE_TIERS) if(price < t.max) return t.c; return '#f87171'; }
-function markerColor(p){ return MODE==='price' ? priceColor(p.price) : (COLORS[p.type]||'#888'); }
+function markerColor(p){
+ if (MODE === 'price') return priceColor(p.price);
+ if (MODE === 'score') return DealScore.scoreColor(scoreFor(p));
+ return COLORS[p.type] || '#888';
+}
// cluster bubbles colored by the group's type (per-type groups → one type per cluster)
function clusterIcon(type){ return cluster => { const n=cluster.getChildCount();
const s = n<10?30:n<100?38:n<1000?46:54;
@@ -109,6 +132,7 @@ function popupHtml(p){
return `<div class="pop"><h3>${p.address||'(address n/a)'}</h3><div class="ad">${p.city||''} ${p.zip||''}</div>`+
`<div class="row"><span class="tag" style="border-color:${COLORS[p.type]||'#888'}">${p.type}</span>`+
(p.price?`<span class="tag price">${money(p.price)}</span>`:'')+
+ ((s=>s!=null?`<span class="tag" style="border-color:${DealScore.scoreColor(s)};color:${DealScore.scoreColor(s)}" title="Unified Deal Score (0-100): blend of pro-forma rent yield, cap rate, DSCR-readiness, and assumable bonus. A screen, not an appraisal.">⬡ Deal ${s}</span>`:'')(scoreFor(p)))+
(p.assum?`<span class="tag" style="border-color:#34d399;color:#34d399" title="HEURISTIC estimate from property type/era — NOT title-verified. Confirm the recorded deed of trust shows an assumable FHA/VA loan before relying on it.">🔑 Assumable FHA/VA?${p.assum.rate?` · est ~${(+p.assum.rate).toFixed(2)}%`:''}${p.assum.lk==='high'?' (high)':''}</span>`:'')+
`</div>`+
`<img loading="lazy" src="${aerialUrl(p.lat,p.lng)}" alt="aerial view" onerror="this.style.display='none';this.nextElementSibling.textContent='Aerial imagery unavailable for this point';">`+
@@ -120,7 +144,36 @@ function popupHtml(p){
`</div></div>`;
}
-fetch('/data/map-points.json').then(r=>r.ok?r.json():Promise.reject(r.status)).then(data => {
+// Pre-load ranked.json + demographics so listing points get the SAME full Deal Score as the cards
+// (pro-forma rent yield + cap + DSCR + proxy DSCR + assumable). Failure-safe: if either misses, the
+// map still draws (listings just fall back to assumable-only / unscored — never blocks, never NaN).
+const rankedReady = Promise.all([
+ fetch('/data/ranked.json').then(r=>r.ok?r.json():{ranked:[]}).catch(()=>({ranked:[]})),
+ fetch('/data/demographics.json').then(r=>r.ok?r.json():{byCity:{}}).catch(()=>({byCity:{}})),
+]).then(([rk, demo])=>{
+ const DEMO = (demo && demo.byCity) || {};
+ const SCEN = { downPct:25, ratePct:6.75, amortYears:30 }; // standard underwriting for the proxy DSCR
+ const debtSvc = (loan)=>{ if(!(loan>0)) return 0; const r=SCEN.ratePct/100/12, n=SCEN.amortYears*12;
+ if(r===0) return loan/SCEN.amortYears; return loan*(r*Math.pow(1+r,n))/(Math.pow(1+r,n)-1)*12; };
+ for (const p of (rk.ranked||[])){
+ const d = DEMO[(p.city||'').toLowerCase()];
+ let grossYieldPro = null, proPerYear = null;
+ if (d && d.median_gross_rent && p.price>0){
+ const pro = Math.round(d.median_gross_rent*(p.units||1)*1.12);
+ proPerYear = pro*12; grossYieldPro = +(100*proPerYear/p.price).toFixed(2);
+ }
+ let dscr = p.finance ? p.finance.dscr : null, proxyDscr = null;
+ if (p.cap_rate==null && proPerYear && p.price>0){
+ const loan = p.price*(1-SCEN.downPct/100); const ads = debtSvc(loan);
+ if (ads>0) proxyDscr = +((proPerYear*0.6)/ads).toFixed(2);
+ }
+ const ds = DealScore.compute({ grossYieldPro, cap_rate:p.cap_rate, verified:p.verified, dscr, proxyDscr, assum:p.assum, fhaApproved:p.fhaApproved });
+ SCORE_BY_RANKED[p.id] = (ds && ds.score!=null) ? ds.score : null;
+ }
+}).catch(()=>{});
+
+Promise.all([fetch('/data/map-points.json').then(r=>r.ok?r.json():Promise.reject(r.status)), rankedReady])
+ .then(([data]) => {
const pts = data.points||[];
for (const p of pts){
const type = p.type||'Other';
@@ -143,10 +196,15 @@ function wireColorBy(){
const key = document.getElementById('pricekey');
key.innerHTML = '<h4>Price tier</h4>' + PRICE_TIERS.map(t=>`<div class="pr"><span class="dot" style="background:${t.c}"></span>${t.label}</div>`).join('')
+ `<div class="pr"><span class="dot" style="background:${PRICE_NA}"></span>price n/a</div>`;
+ const skey = document.getElementById('scorekey');
+ skey.innerHTML = '<h4>Deal Score</h4>' + DealScore.TIERS.map(t=>`<div class="pr"><span class="dot" style="background:${t.c}"></span>${t.label}</div>`).join('')
+ + `<div class="pr"><span class="dot" style="background:${DealScore.SCORE_NA}"></span>not scored</div>`
+ + `<div class="pr" style="font-size:10px;color:var(--muted);margin-top:4px">Commercial = rent+cap+DSCR; condos = assumable-only.</div>`;
document.querySelectorAll('#colorby button').forEach(b => b.addEventListener('click', () => {
MODE = b.dataset.m;
document.querySelectorAll('#colorby button').forEach(x=>x.classList.toggle('on', x===b));
key.classList.toggle('show', MODE==='price');
+ skey.classList.toggle('show', MODE==='score');
recolor();
}));
}
← d10cdb4 officer-yolo: DTD verdict A (Unified Deal Score) 3-0 for nex
·
back to Commercialrealestate
·
afternoon CRE update 2026-06-29 e2e6fb4 →