[object Object]

← 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

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,'&quot;')}">`+
+      `<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 &amp; 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,'&quot;')}">⬡ 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 →