[object Object]

← back to Commercialrealestate

CRE viewer rebuilt like-new: left filter drawer behind hamburger (collapsed) with rich facets (asset type/firm/submarket/price/cap/units/year/status/budget); capture band widened to all properties + all asset classes

741304ad542471cd060020b3daecbe8ea77016be · 2026-06-27 00:25:01 -0700 · Steve

Files touched

Diff

commit 741304ad542471cd060020b3daecbe8ea77016be
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 27 00:25:01 2026 -0700

    CRE viewer rebuilt like-new: left filter drawer behind hamburger (collapsed) with rich facets (asset type/firm/submarket/price/cap/units/year/status/budget); capture band widened to all properties + all asset classes
---
 public/index.html        | 393 ++++++++++++++++++++++++++++++++---------------
 scripts/sources/firms.js |  36 +++--
 2 files changed, 299 insertions(+), 130 deletions(-)

diff --git a/public/index.html b/public/index.html
index ee9b798..efbc545 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,30 +3,57 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>SFV CRE — $400k Down Investment Ranking</title>
+<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; }
-  * { box-sizing: border-box; }
+  :root { --cols:3; --bg:#0e1116; --card:#161b22; --line:#2a313c; --ink:#e6edf3; --mut:#8b949e; --acc:#3fb950; --warn:#d29922; --bad:#f85149; --blue:#58a6ff; --drawerW:316px; }
+  * { box-sizing:border-box; }
   body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
-  header { padding:20px 28px 12px; border-bottom:1px solid var(--line); }
-  h1 { margin:0 0 4px; font-size:22px; letter-spacing:.2px; }
-  .sub { color:var(--mut); font-size:13px; }
-  details.note { margin:10px 0 2px; max-width:1040px; border:1px solid var(--line); border-radius:10px; background:var(--card); }
-  details.note summary { cursor:pointer; padding:9px 14px; font-size:13px; color:var(--blue); list-style:none; }
-  details.note summary::-webkit-details-marker { display:none; }
-  details.note .body { padding:0 14px 12px; font-size:13px; color:#cdd6e0; }
-  details.note .body b { color:var(--ink); }
-  .controls { display:flex; flex-wrap:wrap; gap:14px; align-items:center; padding:12px 28px; border-bottom:1px solid var(--line); position:sticky; top:0; background:rgba(14,17,22,.96); backdrop-filter:blur(8px); z-index:10; }
-  .controls label { color:var(--mut); font-size:12px; margin-right:6px; text-transform:uppercase; letter-spacing:.5px; }
-  select, .chip, .seg button { background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; cursor:pointer; }
-  .chip.active { border-color:var(--blue); color:var(--blue); }
+
+  /* ---- top bar ---- */
+  header { display:flex; align-items:center; gap:14px; padding:12px 20px; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:30; background:rgba(14,17,22,.97); backdrop-filter:blur(8px); }
+  .burger { display:inline-flex; align-items:center; gap:8px; background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:10px; padding:9px 13px; font-size:15px; cursor:pointer; font-weight:600; }
+  .burger:hover { border-color:var(--blue); }
+  .fcount { display:none; background:var(--blue); color:#04122b; font-size:11px; font-weight:700; border-radius:20px; padding:1px 7px; }
+  .fcount.on { display:inline-block; }
+  .htitle h1 { margin:0; font-size:18px; letter-spacing:.2px; }
+  .htitle .sub { color:var(--mut); font-size:12px; }
+  header .spacer { margin-left:auto; }
   .seg { display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
-  .seg button { border:0; border-radius:0; border-right:1px solid var(--line); }
+  .seg button { background:var(--card); color:var(--ink); border:0; border-right:1px solid var(--line); padding:8px 11px; font-size:12px; cursor:pointer; }
   .seg button:last-child { border-right:0; }
   .seg button.active { background:#1f6feb; color:#fff; }
-  input[type=range] { width:150px; vertical-align:middle; accent-color:var(--blue); }
-  .pricecap { width:120px; background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:6px 8px; font-size:13px; }
-  .grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:16px; padding:20px 28px 30px; }
+
+  /* ---- left drawer ---- */
+  #scrim { position:fixed; inset:0; background:rgba(0,0,0,.5); opacity:0; pointer-events:none; transition:opacity .2s; z-index:38; }
+  #scrim.on { opacity:1; pointer-events:auto; }
+  #drawer { position:fixed; top:0; left:0; height:100vh; width:var(--drawerW); background:#0b0e13; border-right:1px solid var(--line); transform:translateX(-100%); transition:transform .22s ease; z-index:40; overflow-y:auto; padding:0 0 40px; }
+  #drawer.open { transform:translateX(0); box-shadow:6px 0 30px rgba(0,0,0,.4); }
+  .dhead { display:flex; align-items:center; justify-content:space-between; padding:16px 18px 10px; position:sticky; top:0; background:#0b0e13; border-bottom:1px solid var(--line); z-index:2; }
+  .dhead b { font-size:15px; } .dhead .x { background:none; border:0; color:var(--mut); font-size:18px; cursor:pointer; }
+  .fsec { padding:14px 18px; border-bottom:1px solid var(--line); }
+  .fsec h4 { margin:0 0 9px; font-size:11px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); }
+  .chips { display:flex; flex-wrap:wrap; gap:6px; }
+  .chip { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:18px; padding:5px 11px; font-size:12px; cursor:pointer; user-select:none; }
+  .chip:hover { color:var(--ink); }
+  .chip.active { border-color:var(--blue); color:var(--blue); background:rgba(88,166,255,.08); }
+  .chip .ct { color:var(--mut); font-size:10px; margin-left:4px; }
+  .row2 { display:flex; gap:8px; }
+  .fld { flex:1; }
+  .fld label { display:block; font-size:10px; color:var(--mut); margin-bottom:3px; }
+  input[type=text], input[type=number], select { width:100%; background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font-size:13px; }
+  input[type=range] { width:100%; accent-color:var(--blue); }
+  .rangeval { font-size:11px; color:var(--blue); float:right; }
+  .toggle { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; }
+  .resetbtn { width:calc(100% - 36px); margin:14px 18px 0; background:transparent; color:var(--bad); border:1px solid #7d2b28; border-radius:9px; padding:9px; font-size:13px; cursor:pointer; }
+
+  /* ---- results bar + grid ---- */
+  .bar { display:flex; flex-wrap:wrap; align-items:center; gap:14px; padding:11px 20px; border-bottom:1px solid var(--line); position:sticky; top:57px; background:rgba(14,17,22,.95); backdrop-filter:blur(6px); z-index:20; }
+  .bar .count { font-size:13px; color:var(--mut); } .bar .count b { color:var(--ink); }
+  .bar label { font-size:11px; color:var(--mut); text-transform:uppercase; letter-spacing:.5px; }
+  .bar .right { margin-left:auto; display:flex; align-items:center; gap:12px; }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:16px; padding:18px 20px 30px; }
+  @media (max-width:900px){ .grid{ grid-template-columns:repeat(2,1fr);} }
+  @media (max-width:620px){ .grid{ grid-template-columns:1fr;} }
   .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; display:flex; flex-direction:column; gap:10px; position:relative; transition:box-shadow .2s,border-color .2s; }
   .card.uc { opacity:.62; }
   .card.flash { border-color:var(--blue); box-shadow:0 0 0 3px rgba(88,166,255,.35); }
@@ -53,18 +80,20 @@
   .thesis { font-size:13px; color:#cdd6e0; }
   .lst { font-size:12px; color:var(--mut); margin:2px 0 0; padding-left:16px; }
   .src { font-size:12px; } .src a { color:var(--blue); text-decoration:none; }
-  .lcbtn { background:transparent; color:var(--warn); border:1px dashed #6b5320; border-radius:14px; padding:2px 8px; font-size:11px; cursor:pointer; }
-  .lcout { font-size:11px; color:var(--mut); margin-top:6px; }
+  .lcbtn,.histbtn { background:transparent; color:var(--warn); border:1px dashed #6b5320; border-radius:14px; padding:2px 8px; font-size:11px; cursor:pointer; }
+  .histbtn { color:var(--blue); border-color:#284b7a; }
+  .lcout,.histout { font-size:11px; color:var(--mut); margin-top:6px; }
   .lcout a { color:var(--blue); text-decoration:none; }
   .lcout .lc-item { padding:4px 0; border-top:1px solid var(--line); }
   .small { font-size:11px; color:var(--mut); }
-  .foot { color:var(--mut); font-size:12px; padding:0 28px 8px; }
+  .foot { color:var(--mut); font-size:12px; padding:0 20px 8px; }
+  .empty { padding:60px 20px; text-align:center; color:var(--mut); }
 
-  /* Docked chat */
-  #chatwrap { border-top:1px solid var(--line); background:#0b0e13; padding:16px 28px 40px; }
+  /* ---- docked chat ---- */
+  #chatwrap { border-top:1px solid var(--line); background:#0b0e13; padding:16px 20px 40px; }
   #chatwrap h2 { font-size:15px; margin:0 0 2px; }
   #chatwrap .hint { color:var(--mut); font-size:12px; margin-bottom:10px; }
-  #log { display:flex; flex-direction:column; gap:10px; max-height:46vh; overflow-y:auto; padding:4px 2px; }
+  #log { display:flex; flex-direction:column; gap:10px; max-height:42vh; overflow-y:auto; padding:4px 2px; }
   .msg { max-width:80%; padding:9px 13px; border-radius:12px; font-size:14px; line-height:1.45; white-space:pre-wrap; word-wrap:break-word; }
   .msg.u { align-self:flex-end; background:#1f6feb; color:#fff; border-bottom-right-radius:3px; }
   .msg.a { align-self:flex-start; background:var(--card); border:1px solid var(--line); border-bottom-left-radius:3px; }
@@ -81,20 +110,49 @@
 </head>
 <body>
 <header>
-  <h1>San Fernando Valley CRE — Best Investment for $400k Down</h1>
-  <div class="sub" id="sub">loading…</div>
-  <details class="note" id="verdict"><summary>📌 Analyst verdict &amp; due-diligence notes</summary><div class="body" id="verdictBody"></div></details>
+  <button class="burger" id="burger" aria-label="Open filters">☰ Filters <span class="fcount" id="fcount">0</span></button>
+  <div class="htitle">
+    <h1>LA County CRE — Investment Explorer</h1>
+    <div class="sub" id="sub">loading…</div>
+  </div>
+  <div class="spacer"></div>
+  <span class="seg" id="scenario">
+    <button data-s="25" class="active">25% down</button>
+    <button data-s="30">30% down</button>
+    <button data-s="cash">All cash</button>
+  </span>
 </header>
 
-<div class="controls">
-  <div><label>Down payment</label>
-    <span class="seg" id="scenario">
-      <button data-s="25" class="active">25% down</button>
-      <button data-s="30">30% down</button>
-      <button data-s="cash">All cash</button>
-    </span>
+<div id="scrim"></div>
+<aside id="drawer">
+  <div class="dhead"><b>Filters</b><button class="x" id="dclose" aria-label="Close">✕</button></div>
+
+  <div class="fsec"><h4>Asset type</h4><div class="chips" id="fType"></div></div>
+  <div class="fsec"><h4>Listing firm</h4><div class="chips" id="fFirm"></div></div>
+  <div class="fsec"><h4>Submarket / city</h4>
+    <input type="text" id="fCity" placeholder="search city or street… e.g. Encino, Ventura Blvd">
+    <div class="chips" id="fCityChips" style="margin-top:8px"></div>
+  </div>
+  <div class="fsec"><h4>Price</h4>
+    <div class="row2"><div class="fld"><label>Min $</label><input type="number" id="fPriceMin" placeholder="any" step="50000"></div>
+    <div class="fld"><label>Max $</label><input type="number" id="fPriceMax" placeholder="any" step="50000"></div></div>
+  </div>
+  <div class="fsec"><h4>Cap rate <span class="rangeval" id="capVal">any</span></h4>
+    <input type="range" id="fCap" min="0" max="10" step="0.25" value="0">
+    <div class="small">minimum broker cap rate (0 = any)</div>
+  </div>
+  <div class="fsec"><h4>Units / size</h4>
+    <div class="row2"><div class="fld"><label>Min units</label><input type="number" id="fUnits" placeholder="any" min="0" step="1"></div>
+    <div class="fld"><label>Built after</label><input type="number" id="fYear" placeholder="any" min="1900" max="2026" step="1"></div></div>
   </div>
-  <div><label>Sort</label>
+  <div class="fsec"><h4>Status &amp; budget</h4>
+    <div class="chips" id="fStatus">
+      <span class="chip" data-st="all">All</span>
+      <span class="chip" data-st="active">Active only</span>
+    </div>
+    <label class="toggle" style="margin-top:10px"><input type="checkbox" id="fAfford"> In budget ($400k down) only</label>
+  </div>
+  <div class="fsec"><h4>Sort &amp; density</h4>
     <select id="sort">
       <option value="composite">Best Investment (blended)</option>
       <option value="coc">Cash-on-Cash ↓</option>
@@ -104,19 +162,29 @@
       <option value="priceAsc">Price ↑</option>
       <option value="priceDesc">Price ↓</option>
       <option value="cashNeeded">Cash Needed ↑</option>
+      <option value="unitsDesc">Units ↓</option>
     </select>
+    <div style="margin-top:10px"><label class="small">Cards per row <span class="rangeval" id="densVal">3</span></label>
+      <input type="range" id="density" min="1" max="6" step="1" value="3"></div>
   </div>
-  <div id="filters"></div>
-  <div><label>Max $</label><input class="pricecap" id="maxPrice" type="number" placeholder="any" step="50000"></div>
-  <div style="margin-left:auto"><label>Density</label><input type="range" id="density" min="1" max="5" step="1" value="3"></div>
-</div>
+  <button class="resetbtn" id="reset">Reset all filters</button>
+</aside>
 
-<div class="grid" id="grid"></div>
-<div class="foot" id="foot"></div>
+<main>
+  <div class="bar">
+    <span class="count" id="count">…</span>
+    <div class="right">
+      <label>Sort</label>
+      <select id="sortTop" style="width:auto;min-width:180px"></select>
+    </div>
+  </div>
+  <div class="grid" id="grid"></div>
+  <div class="foot" id="foot"></div>
+</main>
 
 <div id="chatwrap">
-  <h2>💬 Ask the analyst &nbsp;<span class="small">— grounded in this ranking · free (Claude CLI)</span></h2>
-  <div class="hint">Ask questions <b>or tell it to drive the view</b>: “show only deals under $1M at 30% down sorted by cash-on-cash”, “why is Canby #1 and is its 7.8% cap real?”, “take me to the Reseda mixed-use”.</div>
+  <h2>💬 Ask the analyst &nbsp;<span class="small">— grounded in this dataset · free (Claude CLI)</span></h2>
+  <div class="hint">Ask questions <b>or tell it to drive the view</b>: “show only multifamily under $1M at 30% down sorted by cash-on-cash”, “take me to the Reseda mixed-use”.</div>
   <div id="log"></div>
   <div class="examples" id="examples"></div>
   <form id="chatform" autocomplete="off">
@@ -128,6 +196,7 @@
 <script src="finance.js"></script>
 <script>
 const $ = s => document.querySelector(s);
+const $$ = s => Array.from(document.querySelectorAll(s));
 const fmt = n => n==null ? '—' : '$'+Math.round(n).toLocaleString();
 const pct = n => n==null ? '—' : n+'%';
 const cls = (v, good, ok) => v==null?'na':(v>=good?'pos':(v>=ok?'':'neg'));
@@ -135,9 +204,12 @@ const cls = (v, good, ok) => v==null?'na':(v>=good?'pos':(v>=ok?'':'neg'));
 const SCEN = { '25':{budget:400000,downPct:25,ratePct:6.75,amortYears:30,closingPct:2.5},
                '30':{budget:400000,downPct:30,ratePct:6.75,amortYears:30,closingPct:2.5},
                'cash':{budget:400000,downPct:100,ratePct:0,amortYears:30,closingPct:2.5} };
-let DATA=null, filter='all', scenario='25', maxPrice=null, history=[];
 
-// recompute finance + composite for every listing under the active scenario
+let DATA=null, scenario='25', history=[];
+// Rich filter state
+const F = { types:new Set(), firms:new Set(), cityQuery:'', priceMin:null, priceMax:null,
+            capMin:0, unitsMin:null, yearMin:null, status:'all', afford:false };
+
 function recompute(){
   const A = SCEN[scenario];
   DATA.ranked.forEach(p=>{
@@ -150,8 +222,6 @@ function recompute(){
   DATA.ranked.forEach((p,i)=>p.rank=i+1);
 }
 
-// Firm/source label: prefer the explicit firm tag (set by refresh-all.js), else derive from the
-// source domain so the 104 legacy listings (pre-firm-field) still show where they came from.
 function firmLabel(p){
   if(p.firm) return p.firm;
   const d=(p.source||'').replace(/^https?:\/\/(www\.)?/,'').split('/')[0]||'';
@@ -161,6 +231,7 @@ function firmLabel(p){
   if(map[d]) return map[d];
   return d ? d.split('.')[0].replace(/^./,c=>c.toUpperCase()) : 'Unknown';
 }
+
 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>`;
@@ -173,13 +244,13 @@ function card(p){
      : `<span class="b warn">cap n/d</span>`;
   const verB = p.verified===true ? `<span class="b ok" title="In-place NOI confirmed against MLS">✓ cap verified</span>`
      : (p.cap_rate!=null ? `<span class="b" title="Broker-stated cap, not independently verified">broker cap</span>`
-     : `<span class="b warn" title="No in-place income disclosed — return unverifiable, ranked down">⚠ no income data</span>`);
+     : `<span class="b warn" title="No in-place income disclosed">⚠ no income data</span>`);
   return `<div class="card ${uc?'uc':''}" id="card-${p.id}">
     <div class="top">
       <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 — higher = rent closer to price">${f.grossYield}% rent yld</span>`:''}${statusB} ${aff} ${rec}</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="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>
@@ -191,71 +262,137 @@ function card(p){
     <div class="thesis">${p.qwen?.thesis||''}</div>
     ${p.qwen?.risks?.length?`<div><span class="small">Risks</span><ul class="lst">${p.qwen.risks.map(r=>`<li>${r}</li>`).join('')}</ul></div>`:''}
     ${p.upside_note?`<div class="small">💡 ${p.upside_note}</div>`:''}
-    <div class="src"><a href="${p.source}" target="_blank" rel="noopener noreferrer">View listing ↗</a> · <button class="lcbtn" data-id="${p.id}" title="Pull fresh comps from LoopNet via a cloud browser (~$0.03)">🔄 Live comps (~$0.03)</button> · <button class="histbtn" data-id="${p.id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history from the WhoLivedThere archive (free)">📜 Property history</button></div>
+    <div class="src"><a href="${p.source}" target="_blank" rel="noopener noreferrer">View listing ↗</a> · <button class="lcbtn" data-id="${p.id}" title="Pull fresh comps from LoopNet via a cloud browser (~$0.03)">🔄 Live comps</button> · <button class="histbtn" data-id="${p.id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (free)">📜 Property history</button></div>
     <div class="lcout" id="lc-${p.id}"></div>
     <div class="histout" id="hist-${p.id}"></div>
   </div>`;
 }
 
+// ---- the rich filter pass ----
+function applyFilters(list){
+  return list.filter(p=>{
+    if(F.types.size && !F.types.has(p.type)) return false;
+    if(F.firms.size && !F.firms.has(firmLabel(p))) return false;
+    if(F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
+    if(F.priceMin!=null && p.price<F.priceMin) return false;
+    if(F.priceMax!=null && p.price>F.priceMax) return false;
+    if(F.capMin>0 && !(p.cap_rate!=null && p.cap_rate>=F.capMin)) return false;
+    if(F.unitsMin!=null && !(p.units>=F.unitsMin)) return false;
+    if(F.yearMin!=null && !(p.year_built && p.year_built>=F.yearMin)) return false;
+    if(F.status==='active' && /Under Contract|Pending|OUT OF BUDGET/i.test(p.status)) return false;
+    if(F.afford && !p.finance.affordable) return false;
+    return true;
+  });
+}
+function activeFilterCount(){
+  let n=0;
+  if(F.types.size) n++; if(F.firms.size) n++; if(F.cityQuery) n++;
+  if(F.priceMin!=null||F.priceMax!=null) n++; if(F.capMin>0) n++;
+  if(F.unitsMin!=null) n++; if(F.yearMin!=null) n++;
+  if(F.status!=='all') n++; if(F.afford) n++;
+  return n;
+}
+
+const SORTS={ 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) };
+const num=v=>v==null?-1e9:v;
+
 function render(){
-  let r = DATA.ranked.slice();
-  if(filter==='affordable') r=r.filter(p=>p.finance.affordable);
-  else if(filter==='active') r=r.filter(p=>!/Under Contract|Pending|OUT OF BUDGET/i.test(p.status));
-  else if(filter!=='all') r=r.filter(p=>p.type.toLowerCase().includes(filter));
-  if(maxPrice) r=r.filter(p=>p.price<=maxPrice);
-  const s=$('#sort').value, num=v=>v==null?-1e9:v;
-  const S={ 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 };
-  r.sort(S[s]);
-  $('#grid').innerHTML = r.map(card).join('');
-  $('#foot').textContent = `${r.length} of ${DATA.ranked.length} shown · ${scenario==='cash'?'all-cash':SCEN[scenario].downPct+'% down @ '+SCEN[scenario].ratePct+'%'} · * →X% = projected cap after value-add (not in-place).`;
+  let r = applyFilters(DATA.ranked.slice());
+  r.sort(SORTS[$('#sort').value]||SORTS.composite);
+  $('#grid').innerHTML = r.length ? r.map(card).join('')
+    : `<div class="empty">No properties match these filters.<br><span class="small">Loosen a filter or hit “Reset all”.</span></div>`;
+  const fc=activeFilterCount();
+  $('#count').innerHTML = `<b>${r.length}</b> of ${DATA.ranked.length} properties${fc?` · ${fc} filter${fc>1?'s':''} active`:''}`;
+  $('#fcount').textContent=fc; $('#fcount').classList.toggle('on',fc>0);
+  $('#foot').textContent = `${scenario==='cash'?'all-cash':SCEN[scenario].downPct+'% down @ '+SCEN[scenario].ratePct+'%'} · * →X% = projected cap after value-add (not in-place). Cap rates broker-stated — verify NOI in DD.`;
+  saveState();
 }
 
-// ---- context for the chat: the live ranking, compactly ----
+// ---- build the filter UI from the data ----
+function buildFacets(){
+  const tCount={}, fCount={}, cCount={};
+  DATA.ranked.forEach(p=>{ tCount[p.type]=(tCount[p.type]||0)+1; const fl=firmLabel(p); fCount[fl]=(fCount[fl]||0)+1; if(p.city) cCount[p.city]=(cCount[p.city]||0)+1; });
+  const chipHTML=(obj,key)=>Object.entries(obj).sort((a,b)=>b[1]-a[1])
+    .map(([k,c])=>`<span class="chip" data-${key}="${k.replace(/"/g,'&quot;')}">${k}<span class="ct">${c}</span></span>`).join('');
+  $('#fType').innerHTML=chipHTML(tCount,'t');
+  $('#fFirm').innerHTML=chipHTML(fCount,'fm');
+  // top 14 cities as quick chips; the search box covers the rest
+  const topCities=Object.entries(cCount).sort((a,b)=>b[1]-a[1]).slice(0,14);
+  $('#fCityChips').innerHTML=topCities.map(([k,c])=>`<span class="chip" data-cy="${k.replace(/"/g,'&quot;')}">${k}<span class="ct">${c}</span></span>`).join('');
+  // sort options mirrored into the top bar
+  $('#sortTop').innerHTML=$('#sort').innerHTML;
+}
+
+// ---- chat context ----
 function getContext(){
-  const a=DATA.assumptions, lines=[];
-  lines.push(`MARKET: ${DATA.meta.market}. Budget: $400,000 down. Active scenario: ${scenario==='cash'?'ALL CASH':SCEN[scenario].downPct+'% down, '+SCEN[scenario].ratePct+'%/'+SCEN[scenario].amortYears+'yr, '+SCEN[scenario].closingPct+'% closing'}.`);
-  lines.push(`Filter=${filter}, sort=${$('#sort').value}, maxPrice=${maxPrice||'none'}.`);
-  lines.push(`Verdict: DTD panel 3/3 picked 7302-7304 Canby Ave Reseda (id canby7304). Its 7.8% cap is PROJECTED; actual in-place ~5.96% (NOI $74,497).`);
+  const lines=[];
+  lines.push(`MARKET: ${DATA.meta.market}. Budget: $400,000 down. Active scenario: ${scenario==='cash'?'ALL CASH':SCEN[scenario].downPct+'% down, '+SCEN[scenario].ratePct+'%/'+SCEN[scenario].amortYears+'yr'}.`);
+  lines.push(`Active filters: types=[${[...F.types]}], firms=[${[...F.firms]}], city~"${F.cityQuery}", price ${F.priceMin||'any'}-${F.priceMax||'any'}, capMin=${F.capMin}, unitsMin=${F.unitsMin||'any'}, builtAfter=${F.yearMin||'any'}, status=${F.status}, inBudgetOnly=${F.afford}, sort=${$('#sort').value}.`);
   lines.push('RANKED PROPERTIES (current scenario):');
-  DATA.ranked.forEach(p=>{ const f=p.finance;
-    lines.push(`#${p.rank} id=${p.id} | ${p.address}, ${p.city} | ${p.type} | $${p.price.toLocaleString()} | ${p.units}u | cap ${p.cap_rate!=null?p.cap_rate+'%':'n/d'}${p.cap_rate_projected?` (proj ${p.cap_rate_projected}%)`:''}${p.verified===true?' VERIFIED':''} | NOI ${f.noi?'$'+f.noi.toLocaleString():'n/d'} | CoC ${f.coc!=null?f.coc+'%':'n/a'} | DSCR ${f.dscr??'n/a'} | cash needed $${f.cashNeeded.toLocaleString()} ${f.affordable?'(in budget)':'(OVER budget)'} | composite ${p.composite} | rec ${p.qwen?.recommendation||'n/a'} | status ${p.status}`);
-    if(p.rent_roll) lines.push('   rent roll: '+p.rent_roll.map(u=>`#${u.unit} ${u.type} $${u.rent}/mo`+(u.proforma?` (proj $${u.proforma})`:'')).join('; '));
+  applyFilters(DATA.ranked).slice(0,120).forEach(p=>{ const f=p.finance;
+    lines.push(`#${p.rank} id=${p.id} | ${p.address}, ${p.city} | ${p.type} | $${p.price.toLocaleString()} | ${p.units}u | cap ${p.cap_rate!=null?p.cap_rate+'%':'n/d'}${p.verified===true?' VERIFIED':''} | firm ${firmLabel(p)} | CoC ${f.coc!=null?f.coc+'%':'n/a'} | DSCR ${f.dscr??'n/a'} | cash needed $${f.cashNeeded.toLocaleString()} ${f.affordable?'(in budget)':'(OVER)'} | composite ${p.composite} | status ${p.status}`);
   });
-  if(DATA.comps){
+  if(DATA.comps && DATA.comps.submarket_cap_benchmarks){
     lines.push('\nSUBMARKET CAP BENCHMARKS:');
     Object.entries(DATA.comps.submarket_cap_benchmarks).forEach(([k,v])=>lines.push(`- ${k}: ${v}`));
-    lines.push('COMPARABLE SALES (real, scraped 2026-06-21 — directional, verify in DD):');
-    DATA.comps.comps.forEach(c=>lines.push(`- ${c.submarket} | ${c.address} | ${c.units}u | $${c.price.toLocaleString()} | ${c.cap?c.cap+'% cap':'cap n/d'}${c.ppu?` | $${c.ppu.toLocaleString()}/unit`:''} | ${c.status}${c.note?' ('+c.note+')':''}`));
   }
   const lc = Object.entries(window.liveComps||{}).filter(([,v])=>v&&v.length);
-  if(lc.length){ lines.push('\nLIVE COMPS PULLED THIS SESSION (fresh web search):');
-    lc.forEach(([id,arr])=>{ const p=DATA.ranked.find(x=>x.id===id); lines.push(`for ${p?p.address:id}:`); arr.forEach(c=>lines.push(`  - ${c.title} | ${[c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · ')} | ${c.url}`)); }); }
+  if(lc.length){ lines.push('\nLIVE COMPS THIS SESSION:'); lc.forEach(([id,arr])=>{ const p=DATA.ranked.find(x=>x.id===id); lines.push(`for ${p?p.address:id}:`); arr.forEach(c=>lines.push(`  - ${c.title} | ${[c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · ')}`)); }); }
   return lines.join('\n');
 }
 
-// ---- apply a chat-issued action to the viewer ----
-const VALID = { filter:['all','multifamily','retail','mixed','affordable','active'],
-  sort:['composite','coc','cap','dscr','priceAsc','priceDesc','cashNeeded'], scenario:['25','30','cash'] };
+// ---- chat-issued actions (extended for the rich filters) ----
+const TYPE_ALIASES={multifamily:'Multifamily',retail:'Retail',office:'Office',industrial:'Industrial','mixed':'Mixed-use','mixed-use':'Mixed-use',land:'Land',hospitality:'Hospitality'};
 function applyAction(a){
   const done=[];
-  if(a.scenario && VALID.scenario.includes(String(a.scenario))){ setScenario(String(a.scenario)); done.push('scenario→'+a.scenario); }
-  if(a.filter && VALID.filter.includes(a.filter)){ filter=a.filter; syncChips(); done.push('filter→'+a.filter); }
-  if(a.type && VALID.filter.includes(a.type)){ filter=a.type; syncChips(); done.push('filter→'+a.type); }
-  if(a.sort && VALID.sort.includes(a.sort)){ $('#sort').value=a.sort; localStorage.setItem('sfv_sort',a.sort); done.push('sort→'+a.sort); }
-  if(a.maxPrice!=null && !isNaN(+a.maxPrice)){ maxPrice=+a.maxPrice; $('#maxPrice').value=+a.maxPrice; done.push('max $'+(+a.maxPrice).toLocaleString()); }
-  if(a.minPrice!=null){ /* minPrice handled via maxPrice-style filter could be added; keep maxPrice primary */ }
-  render();
+  if(a.scenario && SCEN[String(a.scenario)]){ setScenario(String(a.scenario)); done.push('scenario→'+a.scenario); }
+  if(a.sort && SORTS[a.sort]){ $('#sort').value=a.sort; $('#sortTop').value=a.sort; done.push('sort→'+a.sort); }
+  const setType=v=>{ const key=TYPE_ALIASES[String(v).toLowerCase()]; if(key){ F.types=new Set([key]); done.push('type→'+key); } };
+  if(a.type) setType(a.type);
+  if(a.filter && a.filter!=='all' && a.filter!=='active' && a.filter!=='affordable') setType(a.filter);
+  if(a.filter==='affordable'){ F.afford=true; done.push('in-budget only'); }
+  if(a.filter==='active'){ F.status='active'; done.push('active only'); }
+  if(a.filter==='all'){ F.types.clear(); F.afford=false; F.status='all'; done.push('cleared'); }
+  if(a.firm){ F.firms=new Set([a.firm]); done.push('firm→'+a.firm); }
+  if(a.city){ F.cityQuery=String(a.city).toLowerCase(); $('#fCity').value=a.city; done.push('city→'+a.city); }
+  if(a.maxPrice!=null && !isNaN(+a.maxPrice)){ F.priceMax=+a.maxPrice; $('#fPriceMax').value=+a.maxPrice; done.push('max $'+(+a.maxPrice).toLocaleString()); }
+  if(a.minPrice!=null && !isNaN(+a.minPrice)){ F.priceMin=+a.minPrice; $('#fPriceMin').value=+a.minPrice; done.push('min $'+(+a.minPrice).toLocaleString()); }
+  if(a.capMin!=null && !isNaN(+a.capMin)){ F.capMin=+a.capMin; $('#fCap').value=+a.capMin; $('#capVal').textContent=F.capMin?F.capMin+'%+':'any'; done.push('cap ≥'+a.capMin+'%'); }
+  if(a.unitsMin!=null && !isNaN(+a.unitsMin)){ F.unitsMin=+a.unitsMin; $('#fUnits').value=+a.unitsMin; done.push('units ≥'+a.unitsMin); }
+  syncChips(); render();
   if(a.highlightId){ const el=$('#card-'+a.highlightId); if(el){ el.scrollIntoView({behavior:'smooth',block:'center'}); el.classList.add('flash'); setTimeout(()=>el.classList.remove('flash'),2200); done.push('→'+a.highlightId); } }
   return done;
 }
 
-function setScenario(s){ scenario=s; localStorage.setItem('sfv_scenario',s);
-  document.querySelectorAll('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===s));
-  recompute(); }
-function syncChips(){ document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('active',c.dataset.f===filter)); }
+function setScenario(s){ scenario=s; localStorage.setItem('cre_scenario',s);
+  $$('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===s)); recompute(); }
+
+function syncChips(){
+  $$('#fType .chip').forEach(c=>c.classList.toggle('active',F.types.has(c.dataset.t)));
+  $$('#fFirm .chip').forEach(c=>c.classList.toggle('active',F.firms.has(c.dataset.fm)));
+  $$('#fCityChips .chip').forEach(c=>c.classList.toggle('active',F.cityQuery===c.dataset.cy.toLowerCase()));
+  $$('#fStatus .chip').forEach(c=>c.classList.toggle('active',F.status===c.dataset.st));
+}
+
+// ---- persistence ----
+function saveState(){ try{ localStorage.setItem('cre_filters', JSON.stringify({
+  types:[...F.types],firms:[...F.firms],cityQuery:F.cityQuery,priceMin:F.priceMin,priceMax:F.priceMax,
+  capMin:F.capMin,unitsMin:F.unitsMin,yearMin:F.yearMin,status:F.status,afford:F.afford,sort:$('#sort').value })); }catch(e){} }
+function loadState(){ try{ const s=JSON.parse(localStorage.getItem('cre_filters')||'{}');
+  if(s.types) F.types=new Set(s.types); if(s.firms) F.firms=new Set(s.firms);
+  F.cityQuery=s.cityQuery||''; F.priceMin=s.priceMin??null; F.priceMax=s.priceMax??null;
+  F.capMin=s.capMin||0; F.unitsMin=s.unitsMin??null; F.yearMin=s.yearMin??null; F.status=s.status||'all'; F.afford=!!s.afford;
+  if(s.cityQuery) $('#fCity').value=s.cityQuery; if(s.priceMin) $('#fPriceMin').value=s.priceMin; if(s.priceMax) $('#fPriceMax').value=s.priceMax;
+  if(s.capMin){ $('#fCap').value=s.capMin; $('#capVal').textContent=s.capMin+'%+'; } if(s.unitsMin) $('#fUnits').value=s.unitsMin; if(s.yearMin) $('#fYear').value=s.yearMin;
+  $('#fAfford').checked=F.afford; if(s.sort){ $('#sort').value=s.sort; }
+}catch(e){} }
+function resetAll(){ F.types.clear(); F.firms.clear(); F.cityQuery=''; F.priceMin=F.priceMax=F.unitsMin=F.yearMin=null; F.capMin=0; F.status='all'; F.afford=false;
+  ['fCity','fPriceMin','fPriceMax','fUnits','fYear'].forEach(id=>$('#'+id).value=''); $('#fCap').value=0; $('#capVal').textContent='any'; $('#fAfford').checked=false;
+  syncChips(); render(); }
 
 // ---- chat ----
 function addMsg(role, text, actions){
@@ -266,7 +403,6 @@ function addMsg(role, text, actions){
   $('#log').appendChild(d); $('#log').scrollTop=$('#log').scrollHeight; return d;
 }
 function parseAction(reply){ const m=reply.match(/```action\s*([\s\S]*?)```/); if(!m) return null; try{ return JSON.parse(m[1].trim()); }catch{ return null; } }
-
 async function ask(text){
   addMsg('u', text); history.push({role:'user',content:text});
   $('#send').disabled=true; const thinking=addMsg('sys','…thinking');
@@ -275,18 +411,13 @@ async function ask(text){
       body: JSON.stringify({ messages: history, context: getContext() }) });
     const j = await r.json(); thinking.remove();
     if(j.error){ addMsg('sys','⚠ '+j.error); }
-    else {
-      const act = parseAction(j.reply); const applied = act ? applyAction(act) : null;
-      addMsg('a', j.reply, applied);
-      history.push({role:'assistant',content:j.reply});
-    }
+    else { const act=parseAction(j.reply); const applied=act?applyAction(act):null; addMsg('a', j.reply, applied); history.push({role:'assistant',content:j.reply}); }
   }catch(e){ thinking.remove(); addMsg('sys','⚠ '+e.message); }
   $('#send').disabled=false; $('#q').focus();
 }
 
-// ---- live comps (gated, ~$0.01/call) ----
+// ---- property history + live comps ----
 window.liveComps = {};
-// WhoLivedThere property-history: free, on-demand assessor + permit + film history per address.
 async function fetchHistory(btn){
   const id=btn.dataset.id, out=$('#hist-'+id); if(!out) return;
   if(out.dataset.loaded){ out.style.display = out.style.display==='none'?'block':'none'; return; }
@@ -296,7 +427,7 @@ async function fetchHistory(btn){
     const j=await (await fetch('/api/history?'+q)).json();
     out.dataset.loaded='1';
     if(j.available===false){ out.innerHTML='<span class="b warn">⚠ archive offline</span>'; return; }
-    if(!j.matched){ out.innerHTML='<span class="small">📜 No record in the WhoLivedThere archive for this address (curated set — most commercial parcels aren’t in it yet).</span>'; return; }
+    if(!j.matched){ out.innerHTML='<span class="small">📜 No record in the WhoLivedThere archive for this address yet.</span>'; return; }
     const P=j.parcel, money=v=>v?'$'+Number(v).toLocaleString():'—';
     let h='<div class="small" style="margin-top:6px;border-top:1px solid #2a2a2a;padding-top:6px">📜 <b>WhoLivedThere archive</b>';
     if(P){ h+=`<div class="badges" style="margin:4px 0">${P.year_built?`<span class="b">built ${P.year_built}</span>`:''}${P.use_type?`<span class="b type">${P.use_type}</span>`:''}${P.units?`<span class="b">${P.units} unit${P.units>1?'s':''}</span>`:''}${P.total_value?`<span class="b ok" title="LA County assessor total assessed value">assessed ${money(P.total_value)}</span>`:''}${P.land_value?`<span class="b">land ${money(P.land_value)}</span>`:''}${P.improvement_value?`<span class="b">improv ${money(P.improvement_value)}</span>`:''}</div><div class="small">${P.use_description||''} · APN ${P.apn||'—'} · ${P.situs_address||''}</div>`; }
@@ -309,7 +440,7 @@ async function fetchHistory(btn){
 async function fetchLiveComps(id){
   const p = DATA.ranked.find(x=>x.id===id); if(!p) return;
   const out = $('#lc-'+id); if(!out) return;
-  out.innerHTML = '<span class="small">…pulling fresh comps (~$0.01)…</span>';
+  out.innerHTML = '<span class="small">…pulling fresh comps (~$0.03)…</span>';
   try{
     const r = await fetch('/api/comps',{method:'POST',headers:{'Content-Type':'application/json'},
       body:JSON.stringify({address:p.address,city:p.city,type:p.type})});
@@ -317,39 +448,59 @@ async function fetchLiveComps(id){
     if(j.error && !(j.comps&&j.comps.length)){ out.innerHTML = `<span class="b warn">⚠ ${j.error}</span>`; return; }
     window.liveComps[id] = j.comps||[];
     const none = (!j.comps||!j.comps.length)?' — none scraped (source may have blocked)':'';
-    out.innerHTML = `<div class="small">Live comps via ${j.source||'web'} (cost ${j.cost})${none} — ask the chat to compare:</div>` +
+    out.innerHTML = `<div class="small">Live comps via ${j.source||'web'} (cost ${j.cost})${none}:</div>` +
       (j.comps||[]).map(c=>`<div class="lc-item"><a href="${c.url}" target="_blank" rel="noopener noreferrer">${c.title||c.url}</a> — ${[c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · ')}</div>`).join('');
   }catch(e){ out.innerHTML = `<span class="b bad">⚠ ${e.message}</span>`; }
 }
 
+// ---- drawer open/close ----
+function openDrawer(){ $('#drawer').classList.add('open'); $('#scrim').classList.add('on'); }
+function closeDrawer(){ $('#drawer').classList.remove('open'); $('#scrim').classList.remove('on'); }
+
 // ---- boot ----
 fetch('data/comps.json').then(r=>r.json()).then(c=>{ if(DATA) DATA.comps=c; else window.__comps=c; }).catch(()=>{});
 fetch('data/ranked.json').then(r=>r.json()).then(d=>{
   DATA=d; if(window.__comps) DATA.comps=window.__comps;
-  $('#sub').innerHTML = `${d.ranked.length} active listings · <b>${d.meta.market}</b> · budget <b>$400,000</b> down${d.generated?' · sourced '+d.generated:''}`;
-  $('#verdictBody').innerHTML = `<b>DTD panel (3/3):</b> best $400k-down pick = <b>7302-7304 Canby Ave, Reseda</b> (mixed-use, seller financing — the only deal with positive/near-breakeven leverage). <br>
-    <b>DD correction:</b> its headline <b>7.8% cap is PROJECTED</b>; actual in-place ≈ <b>5.96%</b> (NOI $74,497 on $112,200 gross). Price cut $1.399M→$1.25M. <br>
-    <b>Market reality:</b> most SFV multifamily caps (4.5–5%) sit below the ~7.8% debt constant → negative leveraged cash flow at 25% down. Cap rates are broker-stated — verify NOI in due diligence.`;
-  const fs=['all','multifamily','retail','mixed','affordable','active'];
-  $('#filters').innerHTML = fs.map(f=>`<span class="chip ${f==='all'?'active':''}" data-f="${f}">${f[0].toUpperCase()+f.slice(1)}</span>`).join('');
-  $('#filters').onclick=e=>{ if(!e.target.dataset.f) return; filter=e.target.dataset.f; syncChips(); render(); };
-  // persisted controls
-  $('#sort').value = localStorage.getItem('sfv_sort')||'composite';
-  const dv=localStorage.getItem('sfv_density')||'3'; $('#density').value=dv; document.documentElement.style.setProperty('--cols',dv);
-  scenario = localStorage.getItem('sfv_scenario')||'25';
-  document.querySelectorAll('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===scenario));
+  $('#sub').innerHTML = `${d.ranked.length} listings · <b>${d.meta.market}</b>${d.generated?' · sourced '+d.generated:''}`;
+
+  scenario = localStorage.getItem('cre_scenario')||'25';
+  $$('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===scenario));
+  buildFacets();
+  loadState();
+
+  // drawer wiring
+  $('#burger').onclick=openDrawer; $('#dclose').onclick=closeDrawer; $('#scrim').onclick=closeDrawer;
+  $('#reset').onclick=resetAll;
+  // facet chip handlers (multi-select toggles)
+  $('#fType').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; F.types.has(c.dataset.t)?F.types.delete(c.dataset.t):F.types.add(c.dataset.t); syncChips(); render(); };
+  $('#fFirm').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; F.firms.has(c.dataset.fm)?F.firms.delete(c.dataset.fm):F.firms.add(c.dataset.fm); syncChips(); render(); };
+  $('#fCityChips').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; const cy=c.dataset.cy.toLowerCase(); F.cityQuery=(F.cityQuery===cy)?'':cy; $('#fCity').value=(F.cityQuery?c.dataset.cy:''); syncChips(); render(); };
+  $('#fStatus').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; F.status=c.dataset.st; syncChips(); render(); };
+  $('#fCity').oninput=()=>{ F.cityQuery=$('#fCity').value.trim().toLowerCase(); syncChips(); render(); };
+  $('#fPriceMin').oninput=()=>{ F.priceMin=$('#fPriceMin').value?+$('#fPriceMin').value:null; render(); };
+  $('#fPriceMax').oninput=()=>{ F.priceMax=$('#fPriceMax').value?+$('#fPriceMax').value:null; render(); };
+  $('#fCap').oninput=()=>{ F.capMin=+$('#fCap').value; $('#capVal').textContent=F.capMin?F.capMin+'%+':'any'; render(); };
+  $('#fUnits').oninput=()=>{ F.unitsMin=$('#fUnits').value?+$('#fUnits').value:null; render(); };
+  $('#fYear').oninput=()=>{ F.yearMin=$('#fYear').value?+$('#fYear').value:null; render(); };
+  $('#fAfford').onchange=()=>{ F.afford=$('#fAfford').checked; render(); };
   $('#scenario').onclick=e=>{ if(!e.target.dataset.s) return; setScenario(e.target.dataset.s); render(); };
-  $('#sort').onchange=()=>{ localStorage.setItem('sfv_sort',$('#sort').value); render(); };
-  $('#density').oninput=()=>{ document.documentElement.style.setProperty('--cols',$('#density').value); localStorage.setItem('sfv_density',$('#density').value); };
-  $('#maxPrice').oninput=()=>{ maxPrice = $('#maxPrice').value?+$('#maxPrice').value:null; render(); };
+
+  // sort (drawer + top bar mirror each other)
+  $('#sort').onchange=()=>{ $('#sortTop').value=$('#sort').value; render(); };
+  $('#sortTop').onchange=()=>{ $('#sort').value=$('#sortTop').value; render(); };
+  $('#sortTop').value=$('#sort').value;
+  // density
+  const dv=localStorage.getItem('cre_density')||'3'; $('#density').value=dv; $('#densVal').textContent=dv; document.documentElement.style.setProperty('--cols',dv);
+  $('#density').oninput=()=>{ document.documentElement.style.setProperty('--cols',$('#density').value); $('#densVal').textContent=$('#density').value; localStorage.setItem('cre_density',$('#density').value); };
+
   // chat
-  const EX=['Show the rent roll for Schoenborn','What are the comps for Reseda multifamily?','Is Canby priced right vs comps?','Show only deals under $1M at 30% down, sorted by cash-on-cash','Which deals actually cash-flow?'];
+  const EX=['Show only multifamily under $1M, sorted by cash-on-cash','Which deals actually cash-flow at 30% down?','Office and industrial in Pasadena','Take me to the Reseda mixed-use','Best in-budget deals'];
   $('#examples').innerHTML = EX.map(x=>`<button class="ex">${x}</button>`).join('');
   $('#examples').onclick=e=>{ if(e.target.classList.contains('ex')){ $('#q').value=e.target.textContent; $('#q').focus(); } };
   $('#chatform').onsubmit=e=>{ e.preventDefault(); const t=$('#q').value.trim(); if(!t) return; $('#q').value=''; ask(t); };
-  $('#grid').onclick=e=>{ const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; }
-    const h=e.target.closest('.histbtn'); if(h) fetchHistory(h); };
-  recompute(); render();
+  $('#grid').onclick=e=>{ const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; } const h=e.target.closest('.histbtn'); if(h) fetchHistory(h); };
+
+  recompute(); syncChips(); render();
 }).catch(e=>{ $('#sub').textContent='Failed to load data/ranked.json — run: npm run analyze'; });
 </script>
 </body>
diff --git a/scripts/sources/firms.js b/scripts/sources/firms.js
index 40279b8..02fc8cd 100644
--- a/scripts/sources/firms.js
+++ b/scripts/sources/firms.js
@@ -25,11 +25,12 @@ const { LA_COUNTY, inLACounty } = require('./la-county');
 // references it still works — it now points at the full county set.
 const SFV = LA_COUNTY;
 
-// Price band reflects the $400k-down, leveraged budget frame. NOTE: county-wide scope now includes
-// premium markets (Beverly Hills, Malibu, Pacific Palisades, Santa Monica) whose entry prices clear
-// $1.75M — those will return little until MAXP is widened. Override via env for a wider sweep.
-const MINP = +(process.env.CC_MINP || 800000);
-const MAXP = +(process.env.CC_MAXP || 1750000);
+// Capture band — WIDE by default (Steve: "add all properties", 2026-06-27). We capture the whole LA
+// County commercial market and let the VIEWER filter by budget ($400k-down affordability) on top of
+// the full set. MINP just rejects obvious non-prices/typos; MAXP is effectively uncapped. Override
+// via CC_MINP / CC_MAXP to re-narrow a sweep.
+const MINP = +(process.env.CC_MINP || 50000);
+const MAXP = +(process.env.CC_MAXP || 1000000000);
 
 // Address-normalizer used as the cross-firm dedup key (identical rules to the original refresh).
 const norm = s => String(s || '').toLowerCase()
@@ -37,8 +38,13 @@ const norm = s => String(s || '').toLowerCase()
   .replace(/\b(ave|avenue|st|street|blvd|boulevard|dr|drive|rd|road|ln|lane|pl|place|ct|court)\b/g, '')
   .replace(/\s+/g, ' ').trim();
 
-// The two asset classes we navigate, with each firm's URL slug for them.
-const TYPES = [['multifamily', 'Multifamily'], ['retail', 'Retail']];
+// All commercial asset classes we navigate, with each firm's URL slug. Expanded from multifamily+
+// retail to the full set (Steve: "all properties") so office/industrial/land/hospitality are captured.
+const TYPES = [
+  ['multifamily', 'Multifamily'], ['retail', 'Retail'], ['office', 'Office'],
+  ['industrial', 'Industrial'], ['mixed-use', 'Mixed-use'], ['land', 'Land'],
+  ['hospitality', 'Hospitality']
+];
 
 const today = () => new Date().toISOString().slice(0, 10);
 
@@ -215,9 +221,21 @@ const byKey = (key) => FIRMS.find(f => f.key === key);
 // ── Shared normalizer: intermediate asset (+ its firm) -> the canonical listings.json record. ─────
 // This is the EXACT field shape the original refresh produced, so analyze.js / ranked.json need no
 // changes. We add `firm` and `firm_key` so the viewer can facet "who's listing what".
+function classifyType(types) {
+  const s = (Array.isArray(types) ? types.join(' ') : String(types || '')).toLowerCase();
+  if (/multi.?family|apartment|duplex|triplex|fourplex/.test(s)) return 'Multifamily';
+  if (/mixed.?use/.test(s)) return 'Mixed-use';
+  if (/retail|shopping|storefront|net.?lease/.test(s)) return 'Retail';
+  if (/office|medical office/.test(s)) return 'Office';
+  if (/industrial|warehouse|flex|manufactur|distribution/.test(s)) return 'Industrial';
+  if (/hospitality|hotel|motel|lodging/.test(s)) return 'Hospitality';
+  if (/\bland\b|development|lot/.test(s)) return 'Land';
+  if (/self.?storage/.test(s)) return 'Self-storage';
+  if (/mobile.?home|manufactured housing/.test(s)) return 'Mobile-home park';
+  return 'Multifamily';
+}
 function toListing(a, firm) {
-  const type = (a.types || []).some(t => /retail/i.test(t)) ? 'Retail'
-    : (a.types || []).some(t => /mixed/i.test(t)) ? 'Mixed-use' : 'Multifamily';
+  const type = classifyType(a.types);
   const units = (() => {
     const m = String(a.desc || '').match(/(?:\|\s*)?\b(\d{1,3})\s*units?\b/i);
     if (m) { const n = +m[1]; if (n >= 1 && n <= 500 && a.price / n >= 50000) return n; }

← ddd052e CRE: add chunked full-firm LA County sweep runner (run-sweep  ·  back to Commercialrealestate  ·  CRE data: Crexi LA County sweep — 104→1028 properties (all a 46ede35 →