← back to Re Flyer Aggregator
public/sources/index.html
339 lines
<!doctype html><html lang="en"><head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Where the data comes from — CRE pipeline · Abrams</title>
<style>
:root{--navy:#0E2350;--blue:#16357A;--gold:#E8A81C;--ink:#141414;--gray:#6B7280}
*{box-sizing:border-box} body{margin:0;font-family:Inter,-apple-system,Helvetica,Arial,sans-serif;background:#f4f6fb;color:var(--ink)}
/* ---- Nav Agent dockable panel (left OR right) ---- */
#nav{position:fixed;top:0;bottom:0;left:0;width:250px;z-index:30;background:#0b1120;border-right:1px solid #1c2540;color:#cfe0ff;overflow-y:auto;transition:width .18s}
#nav.right{left:auto;right:0;border-right:none;border-left:1px solid #1c2540}
#nav.collapsed{width:36px;overflow:hidden}
#nav .nh{display:flex;justify-content:space-between;align-items:center;padding:16px 14px 6px;font-size:13px;letter-spacing:2px;color:#7fd7ff;font-weight:800}
#nav .nh button{background:#152037;color:#9fb4e6;border:1px solid #24304f;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px;margin-left:5px}
#nav .hint{padding:0 14px 8px;font-size:10px;color:#5a6b92;letter-spacing:1px}
#nav .field{padding:9px 14px 4px;font-size:11px;color:#8aa;text-transform:uppercase;letter-spacing:1px;border-top:1px solid #131c33}
#nav .val{display:flex;justify-content:space-between;align-items:center;padding:6px 14px;cursor:pointer;font-size:13px}
#nav .val:hover{background:#131c33} #nav .val.on{background:#16263f;color:#7fd7ff;font-weight:600}
#nav .val .b{background:#0e2a3a;color:#7fd7ff;border-radius:10px;padding:1px 9px;font-size:11px}
#nav.collapsed .nh span:first-child,#nav.collapsed .hint,#nav.collapsed #navfields{display:none}
body.navL header,body.navL .wrap{margin-left:250px} body.navR header,body.navR .wrap{margin-right:250px}
body.navLc header,body.navLc .wrap{margin-left:36px} body.navRc header,body.navRc .wrap{margin-right:36px}
header{background:var(--navy);color:#fff;padding:18px 26px;transition:margin .18s}
header h1{margin:0;font-size:21px} header .s{color:var(--gold);font-size:12px;margin-top:3px}
.flow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px;font-size:12px}
.flow .n{background:#182444;border:1px solid #2a3a66;border-radius:8px;padding:8px 12px;text-align:center}
.flow .n b{display:block;font-size:18px;color:#7fd7ff} .flow .a{color:#E8A81C;font-size:18px}
.wrap{margin:20px;padding:0 20px;transition:margin .18s} /* full-width — no 1180px cap (Steve) */
h2{font-size:15px;color:var(--navy);margin:22px 0 10px}
.controls{display:flex;gap:12px;align-items:center;margin-bottom:10px;font-size:13px}
.controls input{padding:6px 9px;border:1px solid #d6dced;border-radius:6px}
.clr{color:var(--blue);cursor:pointer;font-size:12px} .stat{color:var(--gray);margin-left:auto}
table{border-collapse:collapse;width:100%;font-size:13px;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.05)}
th{background:#eef1f8;text-align:left;padding:10px 12px;color:#445;cursor:pointer;white-space:nowrap}
td{padding:10px 12px;border-top:1px solid #eef0f6;vertical-align:top} tr:hover td{background:#fbfcff}
.badge{font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;color:#fff}
.direct{background:#0E7C4A} .socialua{background:#8A5A00} .proxy{background:#0b6fb8}
.bar{height:8px;background:#eef0f6;border-radius:5px;overflow:hidden;min-width:80px;margin-top:4px} .bar i{display:block;height:100%;background:#16357A}
a{color:var(--blue);text-decoration:none} a:hover{text-decoration:underline}
.samp{color:#6B7280;font-size:12px;line-height:1.5} .samp b{color:#0E7C4A}
.card{background:#fff;border-radius:10px;padding:16px 18px;box-shadow:0 2px 10px rgba(0,0,0,.05);margin-bottom:12px}
.kv{display:flex;gap:26px;flex-wrap:wrap} .kv div b{display:block;font-size:22px;color:var(--blue)} .kv div span{color:var(--gray);font-size:12px}
.links{margin:8px 0;font-size:13px} .links a{margin-right:14px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.grid .card h3{margin:0 0 8px;font-size:13px;color:var(--navy)} .grid .card{min-height:210px}
.grid canvas{max-height:230px}
.grid .card canvas{cursor:pointer}
/* ---- drill-down modal ---- */
.mask{position:fixed;inset:0;background:rgba(8,12,24,.55);z-index:60;display:none;align-items:flex-start;justify-content:center;padding:56px 16px}
.mask.show{display:flex}
.box{background:#fff;border-radius:12px;max-width:720px;width:100%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.32)}
.mh{display:flex;justify-content:space-between;align-items:center;padding:15px 20px;border-bottom:1px solid #eef0f6}
.mh span{font-weight:800;color:var(--navy);font-size:15px} .mh .mc{color:var(--gray);font-weight:600;font-size:12px;margin-left:8px}
.mh button{background:#f4f6fb;border:1px solid #e0e5f0;border-radius:8px;cursor:pointer;font-size:14px;padding:4px 11px}
.mbody{overflow-y:auto;padding:6px 20px 18px}
.mrow{padding:9px 0;border-top:1px solid #f1f3f9;font-size:13px;line-height:1.5} .mrow:first-child{border-top:none}
.mrow .md{color:#8A5A00;font-size:12px;margin-right:6px} .mrow .mm{color:#9aa;font-size:12px} .mrow b{color:#0E7C4A}
.mtag{display:inline-block;background:#eef0f6;color:#445;border-radius:4px;padding:1px 7px;font-size:11px;margin:2px 4px 0 0}
/* ---- full nav-agent table: draggable + toggleable columns, expandable rows, clickable numbers ---- */
thead th{position:relative} thead th[draggable]{cursor:grab} thead th[draggable]:active{cursor:grabbing} thead th.act{color:var(--blue)} thead th .ar{font-size:9px}
thead th.dragover{background:#dbe4fb}
.rz{position:absolute;right:-3px;top:0;width:8px;height:100%;cursor:col-resize;user-select:none;z-index:3} .rz:hover{background:#16357A22}
#srctable{width:auto;min-width:100%} .tblwrap{overflow-x:auto}
td.xc,th.xc{width:24px;color:#9aa} .car{font-size:10px;cursor:pointer}
tbody tr.srow{cursor:pointer} tbody tr.srow:hover>td{background:#fbfcff} tbody tr.srow.exp>td{background:#eef4ff}
td.num{cursor:pointer} td.num:hover{text-decoration:underline}
tr.detail>td{background:#f7f9ff;padding:6px 14px 12px} .dwrap{font-size:12px;color:#445} .dwrap .dm{color:#6B7280;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dwrap .ds,.samp>div{padding:3px 0;white-space:nowrap} .dwrap .ds{border-top:1px solid #eef1f8} .dwrap .ds a{color:#16357A;text-decoration:none} .samp>div{padding:1px 0} td.samp{min-width:340px}
/* expanded detail articles: stacked under the Recent column, justified, wrapping */
td.detarts{vertical-align:top;padding:4px 12px 12px} td.detarts .ds,.dwrap.just .ds{white-space:normal;text-align:justify;padding:4px 0;border-top:1px solid #eef1f8;line-height:1.5}
td.detarts .ds a,.dwrap.just .ds a{color:#16357A;text-decoration:none}
#nav .col{display:flex;justify-content:space-between;align-items:center;padding:6px 14px;cursor:pointer;font-size:13px;color:#445}
#nav .col:hover{background:#eef1f8} #nav .col .sw{width:30px;height:16px;border-radius:9px;background:#ccd3e6;position:relative;transition:.15s;flex:none}
#nav .col.on .sw{background:#0E7C4A} #nav .col .sw::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#fff;transition:.15s} #nav .col.on .sw::after{left:16px}
/* ---- view-mode switch + density (grid/list/kv), full-width, condense+stack ---- */
.vsw{display:inline-flex;border:1px solid #d6dced;border-radius:7px;overflow:hidden}
.vsw button{background:#fff;border:none;padding:5px 10px;font-size:12px;cursor:pointer;color:#445;border-right:1px solid #eef1f8} .vsw button:last-child{border-right:none} .vsw button.on{background:var(--navy);color:#fff}
.dens{font-size:12px;color:#6B7280;display:inline-flex;align-items:center;gap:6px} .dens input{vertical-align:middle}
#srccards{display:none;width:100%}
#srccards.grid{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:12px}
#srccards.kv{display:grid;grid-template-columns:repeat(var(--cols,2),minmax(0,1fr));gap:12px}
#srccards.list{display:flex;flex-direction:column;gap:6px}
.scard{background:#fff;border:1px solid #e6e9f2;border-radius:9px;padding:13px 15px;box-shadow:0 1px 6px rgba(0,0,0,.05);min-width:0}
.scard .sn{font-weight:700;color:var(--navy);font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .scard .smeta{font-size:11px;color:#6B7280;margin:3px 0 7px}
.scard .snums{display:flex;gap:16px;font-size:12px;margin-bottom:6px} .scard .snums b{color:var(--blue)} .scard .snums .g{color:#0E7C4A} .scard .snums span{cursor:pointer}
.scard .ss{font-size:11px;color:#555;border-top:1px solid #f1f3f9;padding-top:5px;overflow:hidden} .scard .ss a{color:#16357A;text-decoration:none}
.slist{display:flex;align-items:center;gap:12px;background:#fff;border:1px solid #eef1f8;border-radius:7px;padding:7px 12px;font-size:12px;flex-wrap:wrap}
.kvc{background:#fff;border:1px solid #e6e9f2;border-radius:9px;padding:10px 15px;min-width:0} .kvc .kvr{display:flex;justify-content:space-between;gap:10px;padding:3px 0;border-top:1px solid #f4f6fb;font-size:12px} .kvc .kvr:first-child{border-top:none} .kvc .kvr .k{color:#6B7280} .kvc .kvr .v{color:#141414;font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
@media(max-width:820px){.grid{grid-template-columns:1fr}#srccards.grid,#srccards.kv{grid-template-columns:1fr}}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head><body>
<div id="nav"><div class="nh"><span>NAV AGENT</span><span><button id="navdock" title="dock left/right">⇄</button><button id="navcol" title="collapse">‹</button></span></div>
<div class="hint">FIELDS · CLICK A VALUE TO FILTER</div><div id="navfields"></div></div>
<header><h1>Where the data comes from</h1><div class="s">CRE deal pipeline · live provenance · Abrams</div><div class="flow" id="flow"></div></header>
<div class="wrap">
<div class="links" id="links"></div>
<h2>GRAPHS</h2>
<div class="grid">
<div class="card"><h3>Items pulled by source (top 12)</h3><canvas id="gPulled"></canvas></div>
<div class="card"><h3>CRE deals by source (top 12)</h3><canvas id="gDeals"></canvas></div>
<div class="card"><h3>Deals by pull method</h3><canvas id="gMethod"></canvas></div>
<div class="card"><h3>Deals by market</h3><canvas id="gMarket"></canvas></div>
<div class="card"><h3>Broker ad platforms (who advertises where)</h3><canvas id="gPlat"></canvas></div>
<div class="card"><h3>Broker crawl breakdown</h3><canvas id="gCrawl"></canvas></div>
</div>
<h2>DATA SOURCES — RSS feeds & proxies</h2>
<div class="controls"><input id="q" placeholder="search source…" size="18"><span class="clr" id="clr">clear filters</span>
<span class="vsw"><button data-v="table" class="on">▦ Table</button><button data-v="grid">▤ Grid</button><button data-v="list">☰ List</button><button data-v="kv">⋮ KV</button></span>
<label class="dens">Density <input type="range" id="dens" min="1" max="5" step="1" value="3"></label>
<div class="stat" id="sstat"></div></div>
<div class="tblwrap"><table id="srctable"><thead><tr id="thead"></tr></thead><tbody id="tb"></tbody></table></div>
<div id="srccards"></div>
<h2>DATABASE CRAWL — brokers (ads + flyers on their sites)</h2>
<div class="card" id="crawl">Loading crawl status…</div>
</div>
<div id="modal" class="mask"><div class="box"><div class="mh"><span id="mtitle"></span><button id="mx" title="close">✕</button></div><div id="mbody" class="mbody"></div></div></div>
<script>
const MB={'RSS direct':'direct','RSS (social-UA)':'socialua','Google News proxy':'proxy'};
let SRC=null,sortK='deals',dir=-1; let F={method:'',region:''}; let VIEW=localStorage.srcView||'table'; let ARCH_BY_SRC={};
const esc=s=>(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]));
const dt=s=>{if(!s)return'';const d=new Date(s);return isNaN(d)?(''+s).slice(0,11):d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'});};
let CR=null; // broker-crawl records, kept for drill-down
const PLAT={google_ads:'Google',bing_ads:'Bing',meta:'Meta',linkedin:'LinkedIn',tiktok:'TikTok',twitter:'X',pinterest:'Pinterest',reddit:'Reddit',snap:'Snap',amazon:'Amazon',criteo:'Criteo',taboola:'Taboola',outbrain:'Outbrain',adroll:'AdRoll',doubleclick:'DoubleClick'};
// ---- drill-down modal: click any graph → the underlying records ----
function openModal(title,count,items,kind){
document.getElementById('mtitle').innerHTML=esc(title)+(count!=null?` <span class="mc">${count} record${count===1?'':'s'}</span>`:'');
const b=document.getElementById('mbody');
if(!items||!items.length){ b.innerHTML='<div class="mrow" style="color:#9aa">no records for this slice</div>'; }
else if(kind==='broker'){
b.innerHTML=items.map(r=>{const dom=esc(r.domain||r.url||'');const href=r.url||('https://'+(r.domain||''));
const tags=Object.keys(PLAT).filter(k=>r[k]).map(k=>`<span class="mtag">${PLAT[k]}</span>`).join('');
return `<div class="mrow"><a href="${href}" target="_blank" rel="noopener">${dom}</a> ${r.flyer_count?`<span class="mtag">📄 ${r.flyer_count} flyer${r.flyer_count>1?'s':''}</span>`:''} ${r.google_analytics?'<span class="mtag">GA</span>':''} ${tags}</div>`;}).join('');
} else {
const s=items.slice().sort((a,b)=>(Date.parse(b.date)||0)-(Date.parse(a.date)||0));
b.innerHTML=s.map(x=>`<div class="mrow"><span class="md">${dt(x.date)}</span>${x.price?'<b>'+esc(x.price)+'</b> ':''}${x.link?`<a href="${x.link}" target="_blank" rel="noopener">${esc(x.title)}</a>`:esc(x.title)} <span class="mm">${esc(x.market||'')}</span></div>`).join('');
}
document.getElementById('modal').classList.add('show');
}
function closeModal(){document.getElementById('modal').classList.remove('show');}
document.getElementById('mx').onclick=closeModal;
document.getElementById('modal').onclick=e=>{if(e.target.id==='modal')closeModal();};
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
// ---- Nav Agent panel ----
const NAVFIELDS=[['method','METHOD'],['region','MARKET']];
// ---- full nav-agent columns: movable (drag) + toggleable (COLUMNS) + sortable (click) ----
const ALLCOLS=[['name','Source'],['method','Method'],['region','Market'],['pulled','Items pulled'],['deals','Deals kept'],['archived','Archived (all)'],['latest','Latest'],['recent','Recent from this source']];
let COLORDER=JSON.parse(localStorage.srcColOrder||'null')||ALLCOLS.map(c=>c[0]);
let CVIS=JSON.parse(localStorage.srcColVis||'null')||ALLCOLS.reduce((o,c)=>(o[c[0]]=true,o),{});
let COLW=JSON.parse(localStorage.srcColW||'null')||{}; // per-column resized widths (px)
ALLCOLS.forEach(c=>{if(!COLORDER.includes(c[0]))COLORDER.push(c[0]);if(!(c[0] in CVIS))CVIS[c[0]]=true;}); // heal schema drift
const clabel=k=>(ALLCOLS.find(c=>c[0]===k)||[])[1]||k;
const cols=()=>COLORDER.filter(k=>CVIS[k]);
const latestOf=x=>Math.max(0,...(x.samples||[]).map(s=>Date.parse(s.date)||0));
const sortSamp=a=>(a||[]).slice().sort((x,y)=>(Date.parse(y.date)||0)-(Date.parse(x.date)||0)); // newest first
const EXP=new Set();
function renderHead(){const tr=document.getElementById('thead');
tr.innerHTML='<th class="xc"></th>'+cols().map(k=>`<th draggable="true" data-k="${k}" class="${sortK===k?'act':''}" style="${COLW[k]?'width:'+COLW[k]+'px':''}">${clabel(k)}<span class="ar">${sortK===k?(dir<0?' ▼':' ▲'):''}</span><span class="rz" title="drag to resize"></span></th>`).join('');
tr.querySelectorAll('th[data-k]').forEach(th=>{
th.onclick=e=>{if(e.target.classList.contains('rz'))return;const k=th.dataset.k;if(sortK===k)dir*=-1;else{sortK=k;dir=(k==='name'||k==='method'||k==='region')?1:-1;}renderHead();renderSrc();};
th.ondragstart=e=>{e.dataTransfer.setData('text/plain',th.dataset.k);th.classList.add('act');};
th.ondragover=e=>{e.preventDefault();th.classList.add('dragover');};
th.ondragleave=()=>th.classList.remove('dragover');
th.ondrop=e=>{e.preventDefault();const from=e.dataTransfer.getData('text/plain'),to=th.dataset.k;
if(from&&to&&from!==to){const a=COLORDER.filter(c=>c!==from);a.splice(a.indexOf(to),0,from);COLORDER=a;localStorage.srcColOrder=JSON.stringify(COLORDER);}
renderHead();renderSrc();};
});}
// column RESIZE — drag a header's right edge; width persists (#thead is stable across renderHead)
let _rz=null,_rzX=0,_rzW=0;
document.getElementById('thead').addEventListener('mousedown',e=>{ if(!e.target.classList.contains('rz'))return;
e.preventDefault();e.stopPropagation(); const th=e.target.closest('th'); _rz=th;_rzX=e.clientX;_rzW=th.offsetWidth; th.draggable=false; document.body.style.userSelect='none'; });
document.addEventListener('mousemove',e=>{ if(!_rz)return; _rz.style.width=Math.max(56,_rzW+(e.clientX-_rzX))+'px'; });
document.addEventListener('mouseup',()=>{ if(!_rz)return; COLW[_rz.dataset.k]=_rz.offsetWidth; localStorage.srcColW=JSON.stringify(COLW); _rz.draggable=true; document.body.style.userSelect=''; _rz=null; });
function cellHTML(x,k,max){
if(k==='name')return `<td><b>${esc(x.name)}</b></td>`;
if(k==='method')return `<td><span class="badge ${MB[x.method]}">${x.method}</span></td>`;
if(k==='region')return `<td>${esc(x.region)}</td>`;
if(k==='pulled')return `<td class="num" data-modal="pulled" style="color:var(--blue);font-weight:700" title="click to view the items pulled">${x.pulled}<div class="bar"><i style="width:${Math.round(x.pulled/max*100)}%"></i></div></td>`;
if(k==='deals')return `<td class="num" data-modal="deals" style="color:#0E7C4A;font-weight:800" title="click to view the deals kept">${x.deals||'·'}</td>`;
if(k==='archived')return `<td class="num" data-modal="archived" style="color:#6d28d9;font-weight:700" title="ALL articles ever recorded from this source (our growing archive)">${(x.archived||0).toLocaleString()}</td>`;
if(k==='latest')return `<td style="white-space:nowrap;color:#8A5A00">${latestOf(x)?dt(new Date(latestOf(x)).toISOString()):'—'}</td>`;
if(k==='recent')return `<td class="samp">${sortSamp(x.samples).slice(0,3).map(s=>`<div><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${esc(s.title)}</a> <span style="color:#9aa">· ${esc(s.market||'')}</span></div>`).join('')||'<span style="color:#c3c9d6">—</span>'}</td>`;
return '<td></td>';}
function detailRow(x){
const ck=cols();
const arts=sortSamp(x.samples).map(s=>`<div class="ds"><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener">${esc(s.title)}</a> <span style="color:#9aa">· ${esc(s.market||'')}</span></div>`).join('')||'<div class="ds" style="color:#9aa">no more articles</div>';
// place the full article list DIRECTLY UNDER the "Recent" column (right side), justified.
if(!ck.includes('recent')) return `<tr class="detail"><td class="xc"></td><td colspan="${ck.length}"><div class="dwrap just">${arts}</div></td></tr>`;
const cells=ck.map(k=>k==='recent'?`<td class="samp detarts">${arts}</td>`:'<td></td>').join('');
return `<tr class="detail"><td class="xc"></td>${cells}</tr>`;}
function buildNav(){ if(!SRC)return; let h='';
for(const [key,lbl] of NAVFIELDS){ const counts={}; SRC.feeds.forEach(x=>{const v=x[key]||'—';counts[v]=(counts[v]||0)+1;});
h+=`<div class="field">${lbl}</div>`;
h+=Object.entries(counts).sort((a,b)=>b[1]-a[1]).map(([v,c])=>`<div class="val${F[key]===v?' on':''}" data-k="${key}" data-v="${esc(v)}"><span>${esc(v)}</span><span class="b">${c}</span></div>`).join('');
}
h+=`<div class="field">COLUMNS · show / hide · drag to reorder</div>`;
h+=ALLCOLS.map(c=>`<div class="col${CVIS[c[0]]?' on':''}" data-col="${c[0]}"><span>${c[1]}</span><span class="sw"></span></div>`).join('');
const nf=document.getElementById('navfields'); nf.innerHTML=h;
nf.querySelectorAll('.val').forEach(el=>el.onclick=()=>{const k=el.dataset.k,v=el.dataset.v; F[k]=F[k]===v?'':v; buildNav(); renderSrc();});
nf.querySelectorAll('.col').forEach(el=>el.onclick=()=>{const c=el.dataset.col;CVIS[c]=!CVIS[c];if(!cols().length)CVIS[c]=true;localStorage.srcColVis=JSON.stringify(CVIS);buildNav();renderHead();renderSrc();});
}
// dock left/right + collapse (persisted)
function applyDock(){ const nav=document.getElementById('nav'); const side=localStorage.navSide||'L', col=localStorage.navCol==='1';
nav.classList.toggle('right',side==='R'); nav.classList.toggle('collapsed',col);
document.body.className=(side==='L'?(col?'navLc':'navL'):(col?'navRc':'navR'));
document.getElementById('navcol').textContent=col?(side==='L'?'›':'‹'):(side==='L'?'‹':'›');
}
document.getElementById('navdock').onclick=()=>{localStorage.navSide=(localStorage.navSide==='R')?'L':'R';applyDock();};
document.getElementById('navcol').onclick=()=>{localStorage.navCol=(localStorage.navCol==='1')?'0':'1';applyDock();};
document.getElementById('clr').onclick=()=>{F={method:'',region:''};document.getElementById('q').value='';buildNav();renderSrc();};
function renderFlow(d){document.getElementById('flow').innerHTML=
`<div class="n"><b>${d.feeds.length}</b>sources</div><span class="a">→</span>
<div class="n"><b>${d.total_items}</b>items pulled</div><span class="a">→</span>
<div class="n"><b>${d.total_deals}</b>CRE deals</div><span class="a">→</span>
<div class="n" style="background:#241a3a;border-color:#4a2f7a"><b style="color:#b061ff">${(d.archive_total||0).toLocaleString()}</b>articles archived</div><span class="a">→</span>
<div class="n"><a href="/search" style="color:#7fd7ff">🔍 search all</a></div>
<div class="n" style="background:transparent;border:none;color:#8aa">updated ${new Date(d.generated).toLocaleString()}</div>`;
document.getElementById('links').innerHTML=`<a href="/search" style="font-weight:700;color:#0E7C4A">🔍 Search · All Listings</a> <a href="/live.html">◂ deal viz</a> <a href="/advertising">broker ad intel ▸</a> <a href="/advertising/broker-ad-signals.csv" download>⬇ broker CSV</a>`;
}
function feedsFiltered(){ const q=document.getElementById('q').value.toLowerCase();
let f=SRC.feeds.filter(x=>x.name.toLowerCase().includes(q)&&(!F.method||x.method===F.method)&&(!F.region||(x.region||'—')===F.region));
f.sort((a,b)=>{ if(sortK==='name'||sortK==='method'||sortK==='region')return (''+a[sortK]).localeCompare(''+b[sortK])*dir;
if(sortK==='latest'||sortK==='recent')return (latestOf(a)-latestOf(b))*dir; return ((a[sortK]||0)-(b[sortK]||0))*dir; });
return f; }
const ld=x=>latestOf(x)?dt(new Date(latestOf(x)).toISOString()):'—';
function cardHTML(x){
const samp=sortSamp(x.samples).slice(0,VIEW==='list'?1:3).map(s=>`<div><span style="color:#8A5A00">${dt(s.date)}</span> ${s.price?'<b>'+esc(s.price)+'</b> ':''}<a href="${s.link}" target="_blank" rel="noopener">${esc((s.title||'').slice(0,64))}</a></div>`).join('');
if(VIEW==='kv')return `<div class="kvc" data-n="${esc(x.name)}"><div class="kvr"><span class="k">Source</span><span class="v">${esc(x.name)}</span></div><div class="kvr"><span class="k">Method</span><span class="v">${x.method}</span></div><div class="kvr"><span class="k">Market</span><span class="v">${esc(x.region)}</span></div><div class="kvr num" data-modal="pulled" style="cursor:pointer"><span class="k">Items pulled</span><span class="v" style="color:var(--blue)">${x.pulled}</span></div><div class="kvr num" data-modal="deals" style="cursor:pointer"><span class="k">Deals kept</span><span class="v" style="color:#0E7C4A">${x.deals||0}</span></div><div class="kvr"><span class="k">Latest</span><span class="v">${ld(x)}</span></div></div>`;
if(VIEW==='list')return `<div class="slist" data-n="${esc(x.name)}"><b style="min-width:150px">${esc(x.name)}</b><span class="badge ${MB[x.method]}">${x.method}</span><span style="color:#6B7280">${esc(x.region)}</span><span class="num" data-modal="pulled" style="cursor:pointer">pulled <b style="color:var(--blue)">${x.pulled}</b></span><span class="num" data-modal="deals" style="cursor:pointer">kept <b style="color:#0E7C4A">${x.deals||0}</b></span><span style="color:#8A5A00">${ld(x)}</span><span style="flex:1;min-width:180px;color:#555;overflow:hidden">${samp}</span></div>`;
return `<div class="scard" data-n="${esc(x.name)}"><div class="sn">${esc(x.name)}</div><div class="smeta"><span class="badge ${MB[x.method]}">${x.method}</span> · ${esc(x.region)} · ${ld(x)}</div><div class="snums"><span class="num" data-modal="pulled">pulled <b>${x.pulled}</b></span><span class="num" data-modal="deals">kept <b class="g">${x.deals||0}</b></span></div><div class="ss">${samp||'<span style="color:#c3c9d6">—</span>'}</div></div>`;
}
function renderSrc(){ if(!SRC)return; const f=feedsFiltered();
const tbl=document.getElementById('srctable'),cards=document.getElementById('srccards');
if(VIEW==='table'){ tbl.style.display=''; cards.style.display='none';
const max=Math.max(1,...SRC.feeds.map(x=>x.pulled)); const ck=cols();
document.getElementById('tb').innerHTML=f.map(x=>{ const cells=ck.map(k=>cellHTML(x,k,max)).join('');
return `<tr class="srow${EXP.has(x.name)?' exp':''}" data-n="${esc(x.name)}"><td class="xc"><span class="car">${EXP.has(x.name)?'▾':'▸'}</span></td>${cells}</tr>`+(EXP.has(x.name)?detailRow(x):''); }).join('');
} else { tbl.style.display='none'; cards.style.display=''; cards.className=VIEW; cards.innerHTML=f.map(cardHTML).join(''); }
const fil=(F.method||F.region)?` · filtered ${F.method||''} ${F.region||''}`:'';
document.getElementById('sstat').textContent=`${f.length} of ${SRC.feeds.length} sources${fil} · ${VIEW}`;
}
// TABLE click: number cell -> modal; else expand/collapse row
document.getElementById('tb').addEventListener('click',e=>{ if(e.target.closest('a'))return;
const row=e.target.closest('tr.srow'); if(!row)return; const n=row.dataset.n; const x=SRC.feeds.find(s=>s.name===n); if(!x)return;
const num=e.target.closest('td.num');
if(num){ const m=num.dataset.modal;
if(m==='pulled')openModal(n+' — items pulled',(x.pulled_list||[]).length,x.pulled_list||[],'deal');
else if(m==='archived')openModal(n+' — all archived articles',(ARCH_BY_SRC[n]||[]).length,ARCH_BY_SRC[n]||[],'deal');
else openModal(n+' — deals kept',(x.samples||[]).length,x.samples||[],'deal'); return; }
EXP.has(n)?EXP.delete(n):EXP.add(n); renderSrc();
});
// CARD click: number -> modal
document.getElementById('srccards').addEventListener('click',e=>{ if(e.target.closest('a'))return;
const num=e.target.closest('.num'); const host=e.target.closest('[data-n]'); if(!num||!host)return; const x=SRC.feeds.find(s=>s.name===host.dataset.n); if(!x)return;
const m=num.dataset.modal;
if(m==='pulled')openModal(x.name+' — items pulled',(x.pulled_list||[]).length,x.pulled_list||[],'deal');
else if(m==='archived')openModal(x.name+' — all archived articles',(ARCH_BY_SRC[x.name]||[]).length,ARCH_BY_SRC[x.name]||[],'deal');
else openModal(x.name+' — deals kept',(x.samples||[]).length,x.samples||[],'deal'); });
// VIEW switch + DENSITY
document.querySelectorAll('.vsw button').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;localStorage.srcView=VIEW;document.querySelectorAll('.vsw button').forEach(z=>z.classList.toggle('on',z.dataset.v===VIEW));renderSrc();});
function setDens(){document.documentElement.style.setProperty('--cols',document.getElementById('dens').value);localStorage.srcDens=document.getElementById('dens').value;}
document.getElementById('dens').oninput=setDens;
document.querySelectorAll('.vsw button').forEach(z=>z.classList.toggle('on',z.dataset.v===VIEW));
if(localStorage.srcDens)document.getElementById('dens').value=localStorage.srcDens; setDens();
document.getElementById('q').oninput=renderSrc;
// ---- charts (Chart.js 2D canvas — robust, destroy-before-recreate = no leak) ----
const charts={};
function pickOpts(pick){return pick?{onClick:(e,els)=>{if(els.length)pick(els[0].index);},onHover:(e,els)=>{e.native.target.style.cursor=els.length?'pointer':'default';}}:{};}
function mkBar(id,labels,data,color,horiz,pick){ if(!window.Chart)return; if(charts[id])charts[id].destroy();
charts[id]=new Chart(document.getElementById(id),{type:'bar',data:{labels,datasets:[{data,backgroundColor:color,borderRadius:4}]},
options:{indexAxis:horiz?'y':'x',animation:{duration:400},plugins:{legend:{display:false}},scales:{x:{ticks:{font:{size:10}}},y:{ticks:{font:{size:10},autoSkip:false}}},maintainAspectRatio:false,...pickOpts(pick)}});}
function mkDon(id,labels,data,colors,pick){ if(!window.Chart)return; if(charts[id])charts[id].destroy();
charts[id]=new Chart(document.getElementById(id),{type:'doughnut',data:{labels,datasets:[{data,backgroundColor:colors,borderWidth:0}]},
options:{animation:{duration:400},plugins:{legend:{position:'right',labels:{font:{size:11},boxWidth:12}}},maintainAspectRatio:false,...pickOpts(pick)}});}
function buildSrcCharts(){ if(!SRC||!window.Chart)return;
const bp=[...SRC.feeds].sort((a,b)=>b.pulled-a.pulled).slice(0,12);
mkBar('gPulled',bp.map(f=>f.name),bp.map(f=>f.pulled),'#16357A',true,i=>openModal(bp[i].name+' — recent items',(bp[i].samples||[]).length,bp[i].samples||[],'deal'));
const bd=[...SRC.feeds].filter(f=>f.deals).sort((a,b)=>b.deals-a.deals).slice(0,12);
mkBar('gDeals',bd.map(f=>f.name),bd.map(f=>f.deals),'#0E7C4A',true,i=>openModal(bd[i].name+' — recent deals',(bd[i].samples||[]).length,bd[i].samples||[],'deal'));
const m={}; SRC.feeds.forEach(f=>m[f.method]=(m[f.method]||0)+f.deals); const mkeys=Object.keys(m);
mkDon('gMethod',mkeys,Object.values(m),['#0E7C4A','#8A5A00','#0b6fb8'],i=>{const meth=mkeys[i];const s=[];SRC.feeds.filter(f=>f.method===meth).forEach(f=>(f.samples||[]).forEach(x=>s.push(x)));openModal('Method · '+meth,s.length,s,'deal');});
const mk={}; SRC.feeds.forEach(f=>(f.samples||[]).forEach(s=>{if(s.market)mk[s.market]=(mk[s.market]||0)+1;}));
const mt=Object.entries(mk).sort((a,b)=>b[1]-a[1]).slice(0,10);
mkBar('gMarket',mt.map(x=>x[0]),mt.map(x=>x[1]),'#E8A81C',false,i=>{const mkt=mt[i][0];const s=[];SRC.feeds.forEach(f=>(f.samples||[]).forEach(x=>{if(x.market===mkt)s.push(x);}));openModal('Market · '+mkt,s.length,s,'deal');});
}
function buildCrawlCharts(cr){ if(!window.Chart)return; const done=cr.filter(r=>!r.error);
const nz=Object.keys(PLAT).map(p=>[p,PLAT[p],done.filter(r=>r[p]).length]).filter(x=>x[2]>0).sort((a,b)=>b[2]-a[2]);
mkBar('gPlat',nz.map(x=>x[1]),nz.map(x=>x[2]),'#0b6fb8',true,i=>{const key=nz[i][0];const b=done.filter(r=>r[key]);openModal(nz[i][1]+' advertisers',b.length,b,'broker');});
const bAdv=done.filter(r=>r.paid_count>0), bFly=done.filter(r=>r.flyer_count>0&&!r.paid_count), bGa=done.filter(r=>r.google_analytics&&!r.paid_count&&!r.flyer_count), bNo=done.filter(r=>!r.paid_count&&!r.flyer_count&&!r.google_analytics);
const buckets=[['Advertising',bAdv],['Flyers only',bFly],['GA only',bGa],['No signals',bNo]];
mkDon('gCrawl',buckets.map(x=>x[0]),buckets.map(x=>x[1].length),['#0E7C4A','#E8A81C','#8aa0c0','#dde3f0'],i=>openModal('Brokers · '+buckets[i][0],buckets[i][1].length,buckets[i][1],'broker'));
}
function loadSrc(){Promise.all([
fetch('/sources.json?t='+Date.now()).then(r=>r.json()),
fetch('/article-archive.json?t='+Date.now()).then(r=>r.ok?r.json():[]).catch(()=>[])
]).then(([d,arch])=>{
ARCH_BY_SRC={}; for(const a of arch){(ARCH_BY_SRC[a.source]=ARCH_BY_SRC[a.source]||[]).push(a);}
d.feeds.forEach(f=>f.archived=(ARCH_BY_SRC[f.name]||[]).length);
d.archive_total=arch.length; d.archive_sources=Object.keys(ARCH_BY_SRC).length;
SRC=d;renderFlow(d);buildNav();renderHead();renderSrc();buildSrcCharts();
}).catch(()=>document.getElementById('sstat').textContent='sources load error');}
function loadCrawl(){fetch('/advertising/broker-ad-signals.json?t='+Date.now()).then(r=>r.json()).then(d=>{ CR=d;
const done=d.filter(r=>!r.error),adv=done.filter(r=>r.paid_count>0),fly=done.filter(r=>r.flyer_count>0),ga=done.filter(r=>r.google_analytics);
document.getElementById('crawl').innerHTML=`<div class="kv">
<div><b>${d.length}</b><span>brokers crawled</span></div><div><b>${adv.length}</b><span>running paid ads</span></div>
<div><b>${fly.length}</b><span>with flyers/PDFs on site</span></div><div><b>${ga.length}</b><span>Google Analytics</span></div></div>
<div class="links"><a href="/advertising">open full broker ad-intel table ▸</a></div>
<div class="samp" style="margin-top:10px"><b>Advertisers:</b> ${adv.slice(0,14).map(r=>esc(r.domain)).join(' · ')||'—'}</div>`;
buildCrawlCharts(d);
}).catch(()=>document.getElementById('crawl').textContent='crawl data not available yet (running…)');}
applyDock(); loadSrc(); loadCrawl(); setInterval(()=>{loadSrc();loadCrawl();},20000);
</script> <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body></html>