← 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
M public/index.htmlM scripts/sources/firms.js
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 & 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 & 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 & 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 <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 <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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" 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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" 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,'"')}">${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,'"')}">${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 →