← back to Commercialrealestate
CRCP: infinite scroll across all grid pages (replace hard row caps / all-at-once renders)
38e48205285051236a41dae3f5246300ab94d996 · 2026-08-19 11:41:37 -0700 · Steve
Reveal a batch and load more as a sentinel scrolls near view, instead of dumping
the whole set (which froze heavy pages) or capping at 600/2000/120 with no way to
see the rest. Scroll-aware: window-scroll pages root the observer to the viewport;
inner-scroll panes (broker-grid/fha-loans/residential-brokers #tableView) root to
the pane. All 13 grid pages verified in-browser (reveal→grow, no page errors).
- infinite-scroll.js (new): reusable windowed helper for custom-render pages
- three-view.js: built-in windowing + scroll-container-aware sentinel (covers the 7 three-view pages)
- deals-flow/condos/contractors/direct-listings/broker-grid/fha-loans: wired to the helper
- fha-leads/just-listed/residential-brokers/licensed-agents: lifted redundant slice(0,600) caps so windowing reveals the full set
- index/mls: converted the existing 'show more' button to auto-trigger on scroll
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/broker-grid.htmlM public/condos.htmlM public/contractors.htmlM public/deals-flow.htmlM public/direct-listings.htmlM public/fha-leads.htmlM public/fha-loans.htmlM public/index.htmlA public/infinite-scroll.jsM public/just-listed.htmlM public/licensed-agents.htmlM public/mls.htmlM public/residential-brokers.htmlM public/three-view.js
Diff
commit 38e48205285051236a41dae3f5246300ab94d996
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Aug 19 11:41:37 2026 -0700
CRCP: infinite scroll across all grid pages (replace hard row caps / all-at-once renders)
Reveal a batch and load more as a sentinel scrolls near view, instead of dumping
the whole set (which froze heavy pages) or capping at 600/2000/120 with no way to
see the rest. Scroll-aware: window-scroll pages root the observer to the viewport;
inner-scroll panes (broker-grid/fha-loans/residential-brokers #tableView) root to
the pane. All 13 grid pages verified in-browser (reveal→grow, no page errors).
- infinite-scroll.js (new): reusable windowed helper for custom-render pages
- three-view.js: built-in windowing + scroll-container-aware sentinel (covers the 7 three-view pages)
- deals-flow/condos/contractors/direct-listings/broker-grid/fha-loans: wired to the helper
- fha-leads/just-listed/residential-brokers/licensed-agents: lifted redundant slice(0,600) caps so windowing reveals the full set
- index/mls: converted the existing 'show more' button to auto-trigger on scroll
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/broker-grid.html | 41 ++++++++++--------
public/condos.html | 16 +++++--
public/contractors.html | 24 +++++++----
public/deals-flow.html | 30 +++++++++----
public/direct-listings.html | 8 +++-
public/fha-leads.html | 6 +--
public/fha-loans.html | 35 ++++++++-------
public/index.html | 11 +++++
public/infinite-scroll.js | 96 +++++++++++++++++++++++++++++++++++++++++
public/just-listed.html | 4 +-
public/licensed-agents.html | 2 +-
public/mls.html | 11 +++++
public/residential-brokers.html | 2 +-
public/three-view.js | 82 ++++++++++++++++++++++++++++++++---
14 files changed, 300 insertions(+), 68 deletions(-)
diff --git a/public/broker-grid.html b/public/broker-grid.html
index fc35661..907950a 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -99,6 +99,8 @@
</style>
<link rel="stylesheet" href="/three-view.css">
<script src="/three-view.js"></script>
+ <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
+
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
@@ -308,36 +310,25 @@ function filtered(){
});
return rows;
}
-function render(){
- const rows=filtered(); const cols=visCols();
- $('#count').textContent=`${rows.length} of ${DATA.length} brokers/agents`;
- $('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
- const show=id=>['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==id));
+let _bgInf=null,_bgRows=[],_bgCols=[];
+// Render one window (slice) of brokers into the active view container.
+function _bgPaint(win){ const cols=_bgCols;
if(view==='table'){
- show('#tableView');
- $('#tbody').innerHTML=rows.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
+ $('#tbody').innerHTML=win.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
const csig=cols.map(c=>c.k).join(',');
- // Column SET/ORDER changed → full colgroup rebuild. Same columns (a sort /
- // search / facet re-render) → just re-wire the resize handles the innerHTML
- // rebuild just wiped, preserving current widths. Without this, column resize
- // works on first paint then dies after the first re-render (TK-10088).
+ // Column SET/ORDER changed → full colgroup rebuild. Same columns (a sort / search / grow
+ // re-render) → just re-wire the resize handles the innerHTML rebuild wiped (TK-10088).
if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#tableView table.g')); }
} else if(view==='list'){
- // COMPACT LIST — one scannable row per broker, every VISIBLE column as inline
- // key/value pairs (reuses cell() so mailto/find-email/DRE/geo HTML matches the
- // table + cards exactly). TK-10526 additive 3rd view. Rows share the .brow class
- // so the existing modal/find-email click delegation on document just works.
- show('#listView');
const metaCols=cols.filter(c=>c.k!=='name');
- $('#listView').innerHTML='<div class="tv-rows">'+rows.map(r=>`<div class="tv-lrow brow" data-id="${r.id}" data-name="${esc(r.name)}">`
+ $('#listView').innerHTML='<div class="tv-rows">'+win.map(r=>`<div class="tv-lrow brow" data-id="${r.id}" data-name="${esc(r.name)}">`
+`<div class="tv-lmain"><span class="tv-la">${esc(r.name)}</span><span class="tv-lsub">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</span>${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>`
+`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
} else {
- show('#gridView');
const skipGrid=new Set(['name','firm','agent_type','phone','email','website','linkedin','title']);
const bodyCols=cols.filter(c=>!skipGrid.has(c.k));
- $('#gridView').innerHTML=rows.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
+ $('#gridView').innerHTML=win.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
<div class="nm">${esc(r.name)} ${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>
<div class="fm">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</div>`
+bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span>${cell(r,c)}</span></div>`).join('')
@@ -345,6 +336,18 @@ function render(){
</div>`).join('');
}
}
+function render(){
+ const rows=filtered(); const cols=visCols();
+ $('#count').textContent=`${rows.length} of ${DATA.length} brokers/agents`;
+ $('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
+ const activeSel=view==='table'?'#tableView':view==='list'?'#listView':'#gridView';
+ ['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==activeSel));
+ // Windowed infinite scroll (was rendering ALL ~2,500 brokers at once): reveal a batch,
+ // load more on scroll. reset() repaints the active view from the top on filter/sort/view change.
+ _bgRows=rows; _bgCols=cols;
+ if(!_bgInf&&window.InfScroll) _bgInf=InfScroll.attach({batch:120,getRows:()=>_bgRows,getMount:()=>$(view==='table'?'#tableView':view==='list'?'#listView':'#gridView'),render:_bgPaint});
+ if(_bgInf) _bgInf.reset(); else _bgPaint(rows);
+}
const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return ''; } };
// Date+TIME — only meaningful on our genuine capture timestamps (listed_at). Public-record
diff --git a/public/condos.html b/public/condos.html
index 29c5d3c..8b84d31 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -138,6 +138,7 @@
</style>
<link rel="stylesheet" href="/three-view.css">
<script src="/three-view.js"></script>
+ <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll for grid/table -->
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
@@ -549,17 +550,26 @@ function dbList(rows){
// paint owns the GRID (cards) + TABLE (dense spreadsheet) views; the COMPACT LIST
// view is owned by ThreeView into #condoList. The active container is shown/hidden
// by ThreeView.render(); paint just fills #grid for grid/table.
+let _condoInf=null, _condoRows=[];
function paint(rows){ const g=$('#grid'), lst=$('#condoList');
if(VIEW==='list'){ if(TV) TV.render(); if(g) g.style.display='none'; if(lst) lst.style.display=''; return; }
if(lst) lst.style.display='none'; if(g) g.style.display='';
- if(!rows.length){ g.className='grid'; g.innerHTML='<div class="empty">no condos match these filters.</div>'; lastColSig=''; return; }
+ // Windowed infinite scroll for grid + table (the whole set once froze the page). The helper
+ // reveals a batch and loads more as you scroll; reset() repaints from the top on filter/sort change.
+ _condoRows=rows;
+ if(!_condoInf && window.InfScroll) _condoInf=InfScroll.attach({batch:120,getRows:()=>_condoRows,getMount:()=>$('#grid'),render:paintCondoWindow});
+ if(_condoInf) _condoInf.reset(); else paintCondoWindow(rows); // fallback if helper missing
+}
+// Render one window (slice) of condo rows into #grid for the current grid/table view.
+function paintCondoWindow(win){ const g=$('#grid');
+ if(!win.length){ g.className='grid'; g.innerHTML='<div class="empty">no condos match these filters.</div>'; lastColSig=''; return; }
if(VIEW==='table'){
- g.className='grid listhost'; g.innerHTML=dbList(rows);
+ g.className='grid listhost'; g.innerHTML=dbList(win);
// hand the freshly-rendered table to col-resize.js when the visible columns/order change
const csig=orderedFields().filter(f=>f.col&&fvis(f.k)).map(f=>f.k).join(',');
if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
}
- else { g.className='grid'; g.innerHTML=dbCards(rows); lastColSig=''; }
+ else { g.className='grid'; g.innerHTML=dbCards(win); lastColSig=''; }
}
// page-owned list-view sort (Steve rule: all sortable; survives search re-render) —
// applies a click-header sort on the raw field value, any type. Runs on top of the
diff --git a/public/contractors.html b/public/contractors.html
index 9772c40..eee55e9 100644
--- a/public/contractors.html
+++ b/public/contractors.html
@@ -90,6 +90,7 @@ input.dealf{background:var(--card);border:1px solid var(--line);color:var(--ink)
.lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
</style> <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
+<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
</head><body>
<header>
<h1><b>Contractors for this deal</b> · CSLB licensed</h1>
@@ -278,20 +279,27 @@ function buildRail(){
$('#fCity').innerHTML=chip(ci,'city',14)||'<span style="font-size:11px;color:var(--mut)">—</span>';
$('#fClass').innerHTML=chip(cl,'class',20)||'<span style="font-size:11px;color:var(--mut)">—</span>';
}
+let _ctrInf=null,_ctrRows=[];
+// Render one window (slice) of contractors into #body for the current view.
+function _ctrPaint(win){
+ if(view==='list'){
+ $('#body').innerHTML=`<div class="lst">`+win.map(c=>`<div class="lrow crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer"><div class="lnm">${esc(c.business_name||'—')}</div><div class="lmeta">`+CCOLS.slice(1).map(col=>`<span class="lm"><span class="lk">${esc(col.l)}</span>${cfmt(c,col)}</span>`).join('')+`</div></div>`).join('')+`</div>`;
+ } else if(view==='table'){
+ $('#body').innerHTML=`<div class="cttable"><table><thead><tr>`+CCOLS.map(col=>`<th data-k="${col.k}" style="cursor:pointer" title="Click to sort">${esc(col.l)}${sortKey===col.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+`</tr></thead><tbody>`+win.map(c=>`<tr class="crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer">`+CCOLS.map(col=>`<td>${cfmt(c,col)}</td>`).join('')+`</tr>`).join('')+`</tbody></table></div>`;
+ } else {
+ $('#body').innerHTML=`<div class="grid">${win.map(card).join('')}</div>`;
+ }
+}
function renderBrowse(down,reason){
document.documentElement.style.setProperty('--cols',dens);
if(down){$('#body').innerHTML=emptyState(reason);$('#count').textContent='upstream not reachable';return;}
const rows=filteredSorted();
$('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} shown`;
if(!rows.length){$('#body').innerHTML=emptyState('No licensed contractors match these filters.','Loosen the county / city / class filters, or clear the search.');return;}
- const slice=rows.slice(0,600);
- if(view==='list'){
- $('#body').innerHTML=`<div class="lst">`+slice.map(c=>`<div class="lrow crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer"><div class="lnm">${esc(c.business_name||'—')}</div><div class="lmeta">`+CCOLS.slice(1).map(col=>`<span class="lm"><span class="lk">${esc(col.l)}</span>${cfmt(c,col)}</span>`).join('')+`</div></div>`).join('')+`</div>`;
- } else if(view==='table'){
- $('#body').innerHTML=`<div class="cttable"><table><thead><tr>`+CCOLS.map(col=>`<th data-k="${col.k}" style="cursor:pointer" title="Click to sort">${esc(col.l)}${sortKey===col.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+`</tr></thead><tbody>`+slice.map(c=>`<tr class="crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer">`+CCOLS.map(col=>`<td>${cfmt(c,col)}</td>`).join('')+`</tr>`).join('')+`</tbody></table></div>`;
- } else {
- $('#body').innerHTML=`<div class="grid">${slice.map(card).join('')}</div>`;
- }
+ // Windowed infinite scroll (was rows.slice(0,600)): reveal a batch, load more on scroll.
+ _ctrRows=rows;
+ if(!_ctrInf&&window.InfScroll) _ctrInf=InfScroll.attach({batch:120,getRows:()=>_ctrRows,getMount:()=>$('#body'),render:_ctrPaint});
+ if(_ctrInf) _ctrInf.reset(); else _ctrPaint(rows);
}
// Pull the registry through our own proxy (server injects CONTRACTORS_API_BASE). Server-side county/city
// let the upstream do the heavy filter; we sort/search/paginate client-side.
diff --git a/public/deals-flow.html b/public/deals-flow.html
index 6af392c..7a67b19 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -121,6 +121,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
.lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
</style> <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
+ <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
</head><body>
<header>
<h1><b>Closed Deal Flow</b> · LA Commercial</h1>
@@ -298,6 +299,22 @@ function card(r){
<div class="provline">📋 ${esc(r.source)}</div>
</div>`;
}
+// Infinite scroll: paint only the windowed rows into the active view; the helper reveals
+// more as you scroll (replaces the old hard rows.slice(0,600) cap).
+const _EMPTY='<div style="color:var(--mut);padding:40px">No deals match.</div>';
+function activeDealMount(){ return view==='grid'?$('#grid'):view==='list'?$('#listView'):$('#tableView'); }
+function paintDealRows(win){
+ const g=$('#grid'),lv=$('#listView');
+ if(view==='grid'){
+ g.innerHTML=win.map(card).join('')||_EMPTY;
+ } else if(view==='list' && lv){
+ lv.innerHTML=win.map(r=>`<div class="lrow drow" data-k="${esc(dealKey(r))}" style="cursor:pointer"><div class="lnm">${esc(r.name||'—')}</div><div class="lmeta">`+DCOLS.slice(1).map(c=>`<span class="lm"><span class="lk">${esc(c.l)}</span>${dfmt(r,c)}</span>`).join('')+`</div></div>`).join('')||_EMPTY;
+ } else {
+ $('#dfthead').innerHTML='<tr>'+DCOLS.map(c=>`<th data-k="${c.k}" style="cursor:pointer" title="Click to sort">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
+ $('#dftbody').innerHTML=win.map(r=>`<tr class="drow" data-k="${esc(dealKey(r))}" style="cursor:pointer">`+DCOLS.map(c=>`<td>${dfmt(r,c)}</td>`).join('')+`</tr>`).join('')||`<tr><td colspan="${DCOLS.length}" style="color:var(--mut);padding:30px">No deals match.</td></tr>`;
+ }
+}
+let _dealInf=null;
function render(){
document.documentElement.style.setProperty('--cols',dens);
const rows=filtered();
@@ -314,15 +331,10 @@ function render(){
g.classList.toggle('hide',view!=='grid');
if(tv) tv.classList.toggle('hide',view!=='table');
if(lv) lv.classList.toggle('hide',view!=='list');
- const slice=rows.slice(0,600);
- if(view==='grid'){
- g.innerHTML=slice.map(card).join('')||'<div style="color:var(--mut);padding:40px">No deals match.</div>';
- } else if(view==='list' && lv){
- lv.innerHTML=slice.map(r=>`<div class="lrow drow" data-k="${esc(dealKey(r))}" style="cursor:pointer"><div class="lnm">${esc(r.name||'—')}</div><div class="lmeta">`+DCOLS.slice(1).map(c=>`<span class="lm"><span class="lk">${esc(c.l)}</span>${dfmt(r,c)}</span>`).join('')+`</div></div>`).join('')||'<div style="color:var(--mut);padding:40px">No deals match.</div>';
- } else if(tv){
- $('#dfthead').innerHTML='<tr>'+DCOLS.map(c=>`<th data-k="${c.k}" style="cursor:pointer" title="Click to sort">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
- $('#dftbody').innerHTML=slice.map(r=>`<tr class="drow" data-k="${esc(dealKey(r))}" style="cursor:pointer">`+DCOLS.map(c=>`<td>${dfmt(r,c)}</td>`).join('')+`</tr>`).join('')||`<tr><td colspan="${DCOLS.length}" style="color:var(--mut);padding:30px">No deals match.</td></tr>`;
- }
+ // Windowed infinite scroll (replaces the old rows.slice(0,600) cap): reveal a batch,
+ // load more as the sentinel scrolls into view. reset() repaints the active view from the top.
+ if(!_dealInf) _dealInf=InfScroll.attach({batch:150,getRows:()=>render._rows,getMount:activeDealMount,render:paintDealRows});
+ _dealInf.reset();
}
$('#fSrc').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.src,c.dataset.s);c.classList.toggle('active');writeURLFilters(true);render();});
$('#fUse').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.use,c.dataset.u);c.classList.toggle('active');writeURLFilters(true);render();});
diff --git a/public/direct-listings.html b/public/direct-listings.html
index 4186d8a..88bf631 100644
--- a/public/direct-listings.html
+++ b/public/direct-listings.html
@@ -40,6 +40,7 @@ td.price{color:var(--gold);font-weight:600}
.empty{color:var(--mut);padding:40px;text-align:center}
</style>
<link rel="stylesheet" href="/crcp-theme.css">
+<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
</head><body>
<header>
<h1><b>Direct Listings</b> · our-own dbase</h1>
@@ -87,11 +88,16 @@ function filtered(){
if(!isNaN(+x)&&!isNaN(+y)){x=+x;y=+y;}else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
return x<y?-1*sortDir:x>y?sortDir:0;});
}
+let _dlInf=null,_dlRows=[];
+function _dlRow(x){ return '<tr>'+COLS.map(c=>`<td class="${c.num?'num ':''}${c.cls||''}">${c.r(x)}</td>`).join('')+'</tr>'; }
function render(){
document.documentElement.style.setProperty('--rowpad','7px');
const rows=filtered();
$('#thead').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}" class="${c.num?'num':''}${c.k===sortKey?' sorted':''}">${c.l}${c.k===sortKey?(sortDir===1?' ▲':' ▼'):''}</th>`).join('')+'</tr>';
- $('#tbody').innerHTML=rows.slice(0,2000).map(x=>'<tr>'+COLS.map(c=>`<td class="${c.num?'num ':''}${c.cls||''}">${c.r(x)}</td>`).join('')+'</tr>').join('');
+ // Windowed infinite scroll (was rows.slice(0,2000)): reveal a batch, load more on scroll.
+ _dlRows=rows;
+ if(!_dlInf&&window.InfScroll) _dlInf=InfScroll.attach({batch:150,getRows:()=>_dlRows,getMount:()=>{const b=$('#tbody');return (b&&b.closest('table'))||b;},render:win=>{$('#tbody').innerHTML=win.map(_dlRow).join('');}});
+ if(_dlInf) _dlInf.reset(); else $('#tbody').innerHTML=rows.map(_dlRow).join('');
$('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
$('#empty').hidden=rows.length>0;
}
diff --git a/public/fha-leads.html b/public/fha-leads.html
index 2dbff48..ff8f5d6 100644
--- a/public/fha-leads.html
+++ b/public/fha-leads.html
@@ -140,12 +140,12 @@ function buildRail(){
function renderTable(rows){
$('#head').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
const fs=Math.max(10,15-dens);
- $('#body').innerHTML=rows.slice(0,600).map(r=>`<tr style="font-size:${fs}px">`+COLS.map(c=>`<td class="${c.t==='money'||c.t==='n'||c.t==='pct'?'mono':''}">${cell(r,c)}</td>`).join('')+'</tr>').join('');
+ $('#body').innerHTML=rows.map(r=>`<tr style="font-size:${fs}px">`+COLS.map(c=>`<td class="${c.t==='money'||c.t==='n'||c.t==='pct'?'mono':''}">${cell(r,c)}</td>`).join('')+'</tr>').join('');
}
function renderGrid(rows){
const gv=$('#gridView'); gv.style.setProperty('--cols',dens);
const kv=(l,v)=>`<div><em>${l}</em><span>${v}</span></div>`;
- gv.innerHTML=rows.slice(0,400).map(r=>{
+ gv.innerHTML=rows.map(r=>{
const spread=(r.pmmsBenchmark!=null&&r.rate!=null)?+(r.pmmsBenchmark-r.rate).toFixed(2):null;
return `<div class="fcard">`+
`<div class="fcard-h"><span class="fcard-t">${esc(r.property||'—')}</span>${r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':''}</div>`+
@@ -192,7 +192,7 @@ $('#dens').value=dens;
// don't pass a sortSel — ThreeView just adds the view modes + the compact list.
TV=ThreeView.mount({
seg:'#viewseg', gridMount:'#gridView', listMount:'#listView', tableMount:'#tableView',
- getRows:()=>CURRENT.slice(0,600), // bound every view's DOM to the top-600 by current sort (count still reports the full match total)
+ getRows:()=>CURRENT, // full set — three-view.js windows it via infinite scroll (count reports the full match total)
fields:COLS.filter(c=>!['property','city','state'].includes(c.k)).map(c=>({k:c.k,l:c.l,html:true,calc:r=>cell(r,c)})),
title:r=>({name:r.property||'—', sub:(r.city||'')+(r.state?', '+r.state:'')}),
badgeHtml:r=>r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':'',
diff --git a/public/fha-loans.html b/public/fha-loans.html
index dde3bdc..ccf5ab7 100644
--- a/public/fha-loans.html
+++ b/public/fha-loans.html
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="/crcp-grid.css">
<link rel="stylesheet" href="/three-view.css">
<script src="/three-view.js"></script>
+<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
<script src="/crcp-grid.js"></script>
<style>
:root{
@@ -347,18 +348,13 @@ function filtered(){
}
// ── render: grid (cards) / list (compact) / table (dense) ─────────────────────
-function render(){
- const rows=filtered(), cols=visCols();
- $('#count').innerHTML=`<b>${rows.length.toLocaleString()}</b> of ${ROWS.length.toLocaleString()} loans`;
- const show=id=>['#ftableView','#fgridView','#flistView'].forEach(s=>$(s).classList.toggle('hide',s!==id));
- const ss=$('#fsort'); if(ss) ss.value = sortKey ? sortKey+':'+(sortDir<0?'desc':'asc') : '';
- if(!rows.length){ $('#femptymsg').style.display='block'; $('#femptymsg').textContent='no matches for these filters.'; }
- else $('#femptymsg').style.display='none';
+let _flInf=null,_flRows=[],_flCols=[];
+// Render one window (slice) of loans into the active view (infinite scroll windows the ~7,400 rows).
+function _flPaint(win){ const cols=_flCols;
const propCell=r=>esc(r.property||'(name n/a)')+(r.city||r.state?`<span class="loc">${esc([r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||''))}${r.holder?' · '+esc(r.holder):''}</span>`:'');
if(VIEW==='table'){
- show('#ftableView');
$('#fthead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc sorted':' desc sorted'):''}">${esc(c.l)}<span class="ar"></span></th>`).join('')+'</tr>';
- $('#ftbody').innerHTML=rows.map(r=>`<tr class="frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
+ $('#ftbody').innerHTML=win.map(r=>`<tr class="frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
+cols.map(c=>{const numT=(c.t==='pct'||c.t==='money'||c.t==='num');return `<td class="${c.k==='property'?'prop':''}${numT?' num':''}">`
+(c.k==='property'?propCell(r):cell(r,c))
+'</td>';}).join('')+'</tr>').join('');
@@ -366,18 +362,13 @@ function render(){
if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#ftable')); } // resize handles survive sort/search re-render (TK-10089)
} else if(VIEW==='list'){
- // COMPACT LIST — one scannable row per loan; every visible non-property column
- // as inline key/value pairs (reuses cell() so red-flag badges etc. match).
- show('#flistView');
const metaCols=cols.filter(c=>c.k!=='property');
- $('#flistView').innerHTML='<div class="tv-rows">'+rows.map(r=>`<div class="tv-lrow frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
+ $('#flistView').innerHTML='<div class="tv-rows">'+win.map(r=>`<div class="tv-lrow frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
+`<div class="tv-lmain"><span class="tv-la">${esc(r.property||'(name n/a)')}</span><span class="tv-lsub">${esc([r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||''))}${r.holder?' · '+esc(r.holder):''}</span>${r.red?badge(r):''}</div>`
+`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
} else {
- // GRID — data cards, property title + visible fields as rows.
- show('#fgridView');
const bodyCols=cols.filter(c=>c.k!=='property');
- $('#fgridView').innerHTML=rows.map(r=>`<div class="glass tile frow${r.red?' red':''}" data-id="${esc(r.__i)}" style="padding:16px 18px;cursor:pointer;border:1px solid var(--glassb)">`
+ $('#fgridView').innerHTML=win.map(r=>`<div class="glass tile frow${r.red?' red':''}" data-id="${esc(r.__i)}" style="padding:16px 18px;cursor:pointer;border:1px solid var(--glassb)">`
+`<div style="font-weight:700;color:#fff;margin-bottom:2px">${esc(r.property||'(name n/a)')}</div>`
+`<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px">${esc([r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||''))}${r.holder?' · '+esc(r.holder):''}</div>`
+(r.red?`<div style="margin-bottom:8px">${badge(r)}</div>`:'')
@@ -385,6 +376,18 @@ function render(){
+`</div>`).join('');
}
}
+function render(){
+ const rows=filtered(), cols=visCols();
+ $('#count').innerHTML=`<b>${rows.length.toLocaleString()}</b> of ${ROWS.length.toLocaleString()} loans`;
+ ['#ftableView','#fgridView','#flistView'].forEach(s=>$(s).classList.toggle('hide',s!==(VIEW==='table'?'#ftableView':VIEW==='list'?'#flistView':'#fgridView')));
+ const ss=$('#fsort'); if(ss) ss.value = sortKey ? sortKey+':'+(sortDir<0?'desc':'asc') : '';
+ if(!rows.length){ $('#femptymsg').style.display='block'; $('#femptymsg').textContent='no matches for these filters.'; }
+ else $('#femptymsg').style.display='none';
+ // Windowed infinite scroll (was rendering ALL ~7,400 loans at once).
+ _flRows=rows; _flCols=cols;
+ if(!_flInf&&window.InfScroll) _flInf=InfScroll.attach({batch:120,getRows:()=>_flRows,getMount:()=>$(VIEW==='table'?'#ftableView':VIEW==='list'?'#flistView':'#fgridView'),render:_flPaint});
+ if(_flInf) _flInf.reset(); else _flPaint(rows);
+}
// ── expandable row → detail modal (all fields) ───────────────────────────────
function openDetail(i){
diff --git a/public/index.html b/public/index.html
index e4d086d..18547df 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1617,6 +1617,16 @@ function printCompare(){ document.body.classList.add('printing-compare'); window
let _rows=[], _shown=0;
const PAGE=()=> view==='list'?200:60;
function moreBtn(){ return _shown<_rows.length ? `<div class="showmore-wrap"><button class="showmore" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button></div>` : ''; }
+// Infinite scroll: auto-trigger the "show more" grow when it scrolls near view (reuses the
+// existing #showmore click handler → _shown+=PAGE → paintPage). Viewport-rooted so it works
+// whether the page or an inner pane scrolls. Re-pointed at the fresh button after each repaint.
+let _moreIO=null, _moreBusy=false;
+function wireAutoMore(){
+ const btn=document.getElementById('showmore'); if(!btn) return;
+ if(typeof IntersectionObserver==='undefined') return;
+ if(!_moreIO) _moreIO=new IntersectionObserver(es=>{ if(_moreBusy) return; if(es.some(e=>e.isIntersecting)){ const b=document.getElementById('showmore'); if(b){ _moreBusy=true; b.click(); setTimeout(()=>{_moreBusy=false;},60); } } },{rootMargin:'500px 0px'});
+ _moreIO.disconnect(); _moreIO.observe(btn);
+}
function paintPage(){
const g=$('#grid'); const r=_rows; const slice=r.slice(0,_shown);
if(!r.length){ g.className='grid'; g.innerHTML=`<div class="empty">No properties match these filters.<br><span class="small">Loosen a filter or hit “Reset all”.</span></div>`; return; }
@@ -1628,6 +1638,7 @@ function paintPage(){
else { g.className='grid'; g.innerHTML=slice.map(card).join('')+moreBtn(); enrichAssessor(); }
applyHeat(slice);
resolveContacts(slice); // fetch REAL phones for the visible agents, then repaint (TK-10687)
+ wireAutoMore(); // convert the show-more button into infinite scroll
}
// ---- saved searches (name + restore the full shareable-URL state) ----
const SAVED=(function(){ try{ return JSON.parse(localStorage.getItem('cre_saved')||'[]'); }catch(e){ return []; } })();
diff --git a/public/infinite-scroll.js b/public/infinite-scroll.js
new file mode 100644
index 0000000..71e5e55
--- /dev/null
+++ b/public/infinite-scroll.js
@@ -0,0 +1,96 @@
+/* infinite-scroll.js — reusable windowed infinite scroll for the CRCP custom-render
+ * grid pages (deals-flow, contractors, broker-grid, direct-listings) that DON'T go
+ * through three-view.js (which has its own equivalent windowing built in).
+ *
+ * Instead of dumping the whole filtered set into the DOM (which froze the heavy
+ * pages), reveal BATCH rows and load the next batch as a sentinel scrolls near view.
+ *
+ * Usage:
+ * const inf = InfScroll.attach({
+ * batch: 120,
+ * getRows: () => currentFilteredSortedRows, // full array
+ * getMount: () => document.querySelector(activeContainerSelector), // visible list/grid/table el
+ * render: (windowRows) => { mount.innerHTML = windowRows.map(...).join(''); },
+ * });
+ * // after ANY filter / sort / view change:
+ * inf.reset(); // resets the window to the first batch and repaints
+ * inf.shownCount(); // how many are currently revealed (for a "showing N of M" label)
+ *
+ * Degrades to "render everything" where IntersectionObserver is unavailable.
+ * Zero deps. $0, local.
+ */
+(function (global) {
+ const IO_OK = typeof IntersectionObserver !== 'undefined';
+
+ function attach(O) {
+ const BATCH = O.batch || 120;
+ let shown = BATCH;
+ let last = [];
+ let sentinel = null, io = null, growing = false;
+
+ function ensureSentinel() {
+ if (!IO_OK) return null;
+ if (!sentinel) {
+ sentinel = document.createElement('div');
+ sentinel.className = 'inf-sentinel';
+ sentinel.setAttribute('aria-hidden', 'true');
+ sentinel.style.cssText = 'height:1px;width:100%;';
+ }
+ return sentinel;
+ }
+ // Find the element that actually scrolls (the mount itself or an ancestor with an
+ // overflow:auto/scroll AND real overflow). Returns null when the WINDOW scrolls — the
+ // common case (condos/deals-flow/contractors/direct-listings), where the observer roots
+ // to the viewport and the sentinel sits AFTER the mount. Inner-scroll panes (broker-grid's
+ // fixed-height #tableView) return that pane so we root the observer to it and drop the
+ // sentinel INSIDE it, or window-scroll would never reach the sentinel.
+ function findScroller(el) {
+ let n = el;
+ while (n && n !== document.body && n !== document.documentElement) {
+ const cs = getComputedStyle(n);
+ if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 4) return n;
+ n = n.parentElement;
+ }
+ return null;
+ }
+ let curRoot = undefined; // the IO root we last built for (undefined = none yet)
+ function positionSentinel(hasMore) {
+ const s = ensureSentinel(); if (!s) return;
+ if (!hasMore) { if (s.parentNode) s.parentNode.removeChild(s); if (io) io.unobserve(s); return; }
+ const mount = O.getMount && O.getMount();
+ if (!mount || !mount.parentNode) return;
+ const scroller = findScroller(mount); // null → window scroll
+ // (re)build the observer if the scroll root changed (view switch can change the pane)
+ if (scroller !== curRoot) {
+ if (io) io.disconnect();
+ io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) grow(); }, { root: scroller || null, rootMargin: '600px 0px' });
+ curRoot = scroller;
+ }
+ if (scroller) {
+ // inner-scroll pane → sentinel lives at the BOTTOM of the scrolling content
+ if (s.parentNode !== scroller || scroller.lastElementChild !== s) scroller.appendChild(s);
+ } else {
+ // window scroll → sentinel sits right after the mount in normal flow
+ if (mount.nextSibling !== s) mount.parentNode.insertBefore(s, mount.nextSibling);
+ }
+ io.observe(s);
+ }
+ function paint() {
+ last = (O.getRows && O.getRows()) || [];
+ if (shown > last.length) shown = Math.max(BATCH, Math.min(shown, last.length));
+ const win = IO_OK ? last.slice(0, shown) : last;
+ if (O.render) O.render(win);
+ positionSentinel(IO_OK && shown < last.length);
+ if (O.onPaint) O.onPaint(Math.min(shown, last.length), last.length);
+ }
+ function grow() {
+ if (growing || shown >= last.length) return;
+ growing = true; shown = Math.min(shown + BATCH, last.length); paint(); growing = false;
+ }
+ function reset() { shown = BATCH; paint(); }
+
+ return { reset, paint, grow, shownCount: () => Math.min(shown, last.length), total: () => last.length };
+ }
+
+ global.InfScroll = { attach };
+})(window);
diff --git a/public/just-listed.html b/public/just-listed.html
index 005f13c..8875478 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -322,7 +322,7 @@ function renderGrid(rows){
const host=$('#gridView');
if(dripTimer){clearInterval(dripTimer);dripTimer=null;}
if(!rows.length){host.innerHTML='';return;}
- const cap=rows.slice(0,300);
+ const cap=rows; // three-view.js windows the set via infinite scroll; render all it hands us
const cardHtml=r=>{
const ppu=(r.price&&r.units)?Math.round(r.price/r.units):null;
return `<div class="gcard" data-id="${esc(r.id)}">
@@ -370,7 +370,7 @@ function rowHtml(r){
}
function renderTable(rows){
renderHead();
- $('#tbody').innerHTML=rows.slice(0,600).map(rowHtml).join('');
+ $('#tbody').innerHTML=rows.map(rowHtml).join(''); // three-view.js windows via infinite scroll
}
// ── stats + count (shared across views) ──
diff --git a/public/licensed-agents.html b/public/licensed-agents.html
index ac70448..4c10ebf 100644
--- a/public/licensed-agents.html
+++ b/public/licensed-agents.html
@@ -304,7 +304,7 @@ function renderAgentTable(rows){
const cols=TABLE_COLS();
const t=$('#tableView').querySelector('table');
t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.l)}</th>`).join('')+'</tr>';
- t.querySelector('tbody').innerHTML=rows.slice(0,600).map(a=>{
+ t.querySelector('tbody').innerHTML=rows.map(a=>{
return `<tr data-idx="${DATA.indexOf(a)}" style="cursor:pointer">`+cols.map(c=>`<td>${c.calc(a)}</td>`).join('')+'</tr>';
}).join('');
$('#count').textContent=rows.length+' / '+DATA.length+' agents';
diff --git a/public/mls.html b/public/mls.html
index 3f66288..20a0141 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -344,6 +344,17 @@ function paintBody(){
}
const mw=$('#moreWrap'); if(mw) mw.innerHTML=_shown<_rows.length?`<button class="csvbtn" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button>`:'';
applyHeat(rows);
+ wireAutoMore(); // infinite scroll: auto-load the next page as #showmore nears view
+}
+// Convert the "show more" button into infinite scroll — auto-click it when it scrolls near
+// view (reuses the existing #showmore handler → _shown+=PAGE → paintBody). The manual button
+// still works as a fallback where IntersectionObserver is unavailable.
+let _moreIO=null, _moreBusy=false;
+function wireAutoMore(){
+ const btn=document.getElementById('showmore'); if(!btn) return;
+ if(typeof IntersectionObserver==='undefined') return;
+ if(!_moreIO) _moreIO=new IntersectionObserver(es=>{ if(_moreBusy) return; if(es.some(e=>e.isIntersecting)){ const b=document.getElementById('showmore'); if(b){ _moreBusy=true; b.click(); setTimeout(()=>{_moreBusy=false;},60); } } },{rootMargin:'500px 0px'});
+ _moreIO.disconnect(); _moreIO.observe(btn);
}
// heat cues + copy summary (parity with the Explorer)
let heat=localStorage.getItem('cre_heat_mls')||'none';
diff --git a/public/residential-brokers.html b/public/residential-brokers.html
index ce8dc30..9dfef83 100644
--- a/public/residential-brokers.html
+++ b/public/residential-brokers.html
@@ -159,7 +159,7 @@ function tableCols(){ return FIELDS.filter(f=>f.lock || (!state.off[f.k] && f.k!
function renderTable(rows){
const cols=tableCols(), t=$('tableView').querySelector('table');
t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
- t.querySelector('tbody').innerHTML=rows.slice(0,600).map(r=>
+ t.querySelector('tbody').innerHTML=rows.map(r=>
'<tr>'+cols.map(c=>{
const cell=c.render?c.render(r):(c.get(r)||'—');
return `<td>${c.render?cell:esc(cell)}</td>`;
diff --git a/public/three-view.js b/public/three-view.js
index 3c87c50..4355d7a 100644
--- a/public/three-view.js
+++ b/public/three-view.js
@@ -63,6 +63,69 @@
let SORT = null; // {k, dir}
try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
+ // ---- infinite scroll (windowed reveal) ----------------------------------
+ // Instead of dumping the whole filtered set (which froze heavy pages), reveal
+ // BATCH rows at a time and load more as a sentinel scrolls into view. All three
+ // views (grid/list/table) share one `shown` window; it resets to BATCH whenever
+ // the page re-filters (public render()), re-sorts, or switches view. Degrades to
+ // "show everything" where IntersectionObserver is unavailable.
+ const BATCH = O.batch || 120;
+ const IO_OK = typeof IntersectionObserver !== 'undefined';
+ let shown = BATCH; // how many rows are currently revealed
+ let LAST_ROWS = []; // the full sorted set from the last paint (for grow())
+ let sentinel = null, io = null, growing = false;
+
+ function ensureSentinel() {
+ if (!IO_OK) return null;
+ if (!sentinel) {
+ sentinel = document.createElement('div');
+ sentinel.className = 'tv-sentinel';
+ sentinel.setAttribute('aria-hidden', 'true');
+ sentinel.style.cssText = 'height:1px;width:100%;';
+ }
+ return sentinel;
+ }
+ // The element that actually scrolls (the mount or an ancestor with real overflow), or null
+ // when the WINDOW scrolls. Inner-scroll panes (e.g. residential-brokers' #tableView with
+ // max-height+overflow:auto) must root the observer to the pane and hold the sentinel INSIDE
+ // it, or window-scroll would never reach a sentinel parked after the pane.
+ function findScroller(el) {
+ let n = el;
+ while (n && n !== document.body && n !== document.documentElement) {
+ const cs = getComputedStyle(n);
+ if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 4) return n;
+ n = n.parentElement;
+ }
+ return null;
+ }
+ let curRoot = undefined;
+ // Park the sentinel at the bottom of the revealed content so the observer fires as it nears view.
+ function positionSentinel(hasMore) {
+ const s = ensureSentinel(); if (!s) return;
+ if (!hasMore) { if (s.parentNode) s.parentNode.removeChild(s); if (io) io.unobserve(s); return; }
+ const activeMount = $(VIEW === 'grid' ? O.gridMount : VIEW === 'table' ? O.tableMount : O.listMount);
+ if (!activeMount || !activeMount.parentNode) return;
+ const scroller = findScroller(activeMount); // null → window scroll
+ if (scroller !== curRoot) {
+ if (io) io.disconnect();
+ io = new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting)) grow(); }, { root: scroller || null, rootMargin: '600px 0px' });
+ curRoot = scroller;
+ }
+ if (scroller) {
+ if (s.parentNode !== scroller || scroller.lastElementChild !== s) scroller.appendChild(s);
+ } else if (s.nextSibling !== activeMount.nextSibling || s.previousSibling !== activeMount) {
+ activeMount.parentNode.insertBefore(s, activeMount.nextSibling);
+ }
+ io.observe(s);
+ }
+ function grow() {
+ if (growing || shown >= LAST_ROWS.length) return;
+ growing = true;
+ shown = Math.min(shown + BATCH, LAST_ROWS.length);
+ paint(); // re-render the larger window (append-at-bottom keeps scroll pos)
+ growing = false;
+ }
+
// ---- segmented view toggle ----
function buildSeg() {
const el = $(O.seg); if (!el) return;
@@ -136,18 +199,27 @@
set(O.tableMount, VIEW === 'table');
}
- function render() {
+ // paint the CURRENT window (rows 0..shown). Does NOT reset `shown` — grow() and
+ // the internal re-renders call this so scrolling accumulates instead of snapping back.
+ function paint() {
const raw = O.getRows ? (O.getRows() || []) : [];
- const rows = applySort(raw);
+ LAST_ROWS = applySort(raw);
+ if (shown > LAST_ROWS.length) shown = Math.max(BATCH, Math.min(shown, LAST_ROWS.length));
+ const win = IO_OK ? LAST_ROWS.slice(0, shown) : LAST_ROWS; // no IO → show all
showHide();
- if (VIEW === 'grid' && O.renderGrid) O.renderGrid(rows);
- else if (VIEW === 'table' && O.renderTable) O.renderTable(rows);
- else if (VIEW === 'list') listCompact(rows);
+ if (VIEW === 'grid' && O.renderGrid) O.renderGrid(win);
+ else if (VIEW === 'table' && O.renderTable) O.renderTable(win);
+ else if (VIEW === 'list') listCompact(win);
// keep the seg buttons + sort select reflecting current state
const seg = $(O.seg); if (seg) seg.querySelectorAll('button[data-tvview]').forEach(b => b.classList.toggle('active', b.dataset.tvview === VIEW));
const sel = $(O.sortSel); if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+ positionSentinel(IO_OK && shown < LAST_ROWS.length);
}
+ // public render() — called by the page after a filter change, and internally on
+ // sort/view change. Resets the reveal window to the first batch (back to the top).
+ function render() { shown = BATCH; paint(); }
+
function setView(v) {
if (!VIEWS.includes(v)) return;
VIEW = v; try { localStorage.setItem(KEY + 'View', v); } catch (e) {}
← 77b9299 broker snapshot: active-listing recency gate + size-ceiling
·
back to Commercialrealestate
·
broker profile item 3: honest 'listings as of <date>' snapsh d4447f7 →