Render admin matrix as a drill-down pyramid with open slots

Matrix View now defaults to a generation-by-generation pyramid (root + 3
generations of position cards, empty placements shown as dashed open
slots). Clicking a position re-roots the pyramid there; a breadcrumb
navigates back up. The collapsible list stays behind a toggle. Tree
payload now carries explicit left/right children so an only-child lands
on the correct side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-13 10:36:36 -05:00
parent 7c41499312
commit 1e2b03c795
4 changed files with 72 additions and 16 deletions
+59 -13
View File
@@ -66,24 +66,70 @@ document.getElementById('lookupForm').addEventListener('submit',async e=>{
out.innerHTML=facts+chain+`<p style="font-size:12px;color:#8498aa;margin:14px 0 0">Payments received by #${d.id} — ${d.income?d.income.length:0} total, newest first:</p>`+income;
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
});
const matrixUI={data:null,byId:{},parent:{},rootId:1,mode:'pyramid'};
function mtIndex(n,par){if(!n)return;matrixUI.byId[n.id]=n;if(par)matrixUI.parent[n.id]=par.id;mtIndex(n.left,n);mtIndex(n.right,n)}
function mtBadge(n){return n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>'}
const mtFmt=n=>Number(n||0).toLocaleString(undefined,{maximumFractionDigits:0});
function mtCard(n){
if(!n)return '<div class="mtp-card mtp-open">open<br>slot</div>';
return `<button class="mtp-card${n.id===matrixUI.rootId?' mtp-focus':''}" data-mid="${n.id}">${mtBadge(n)} <span class="mt-id">#${n.id}</span><span class="mtp-lvl">${esc(n.levelName)}</span><span class="mtp-sub">${n.directCount}/2 · ${mtFmt(n.earnedPol)} POL</span></button>`;
}
function renderPyramid(){
const out=document.getElementById('matrixTree'),nav=document.getElementById('matrixNav');
const root=matrixUI.byId[matrixUI.rootId];
if(!root){out.innerHTML='<div class="empty">Position not found.</div>';return}
const GEN=4; // root + 3 generations = up to 15 cards
const rows=[[root]];
for(let g=1;g<GEN;g++){
rows.push(rows[g-1].flatMap(n=>n?[n.left||null,n.right||null]:[null,null]));
}
out.innerHTML=`<div class="mtp">${rows.map(r=>`<div class="mtp-row">${r.map(mtCard).join('')}</div>`).join('')}</div>`;
// breadcrumb: path from tree root down to current focus
const path=[];let cur=matrixUI.rootId;
while(cur){path.unshift(cur);cur=matrixUI.parent[cur]}
nav.classList.remove('hidden');
nav.innerHTML=`<span class="micro" style="margin:0">Viewing:</span> `+path.map((id,i)=>i===path.length-1?`<strong class="mt-id">#${id}</strong>`:`<button class="btn btn-secondary btn-sm" data-mid="${id}">#${id}</button>`).join(' <span style="color:var(--muted)">›</span> ')+
(matrixUI.parent[matrixUI.rootId]?` <button class="btn btn-secondary btn-sm" data-mid="${matrixUI.parent[matrixUI.rootId]}">↑ Up one</button>`:'');
out.querySelectorAll('[data-mid]').forEach(b=>b.addEventListener('click',()=>{matrixUI.rootId=Number(b.dataset.mid);renderPyramid()}));
nav.querySelectorAll('[data-mid]').forEach(b=>b.addEventListener('click',()=>{matrixUI.rootId=Number(b.dataset.mid);renderPyramid()}));
}
function renderList(){
const out=document.getElementById('matrixTree'),d=matrixUI.data;
document.getElementById('matrixNav').classList.add('hidden');
const node=(n,depth)=>{
if(!n)return '';
if(n.missing)return `<li class="mt-node"><span class="mt-id">#${n.id}</span> <span style="color:var(--danger)">(no data)</span></li>`;
const ch=[n.left,n.right].filter(Boolean);
const kids=ch.length?`<ul class="mt-kids">${ch.map(c=>node(c,depth+1)).join('')}</ul>`:'';
const open=depth<4?' open':'';
const label=`${mtBadge(n)} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${mtFmt(n.earnedPol)} POL earned${n.referrerId?` · ref #${n.referrerId}`:''}</span>`;
return ch.length?`<li class="mt-node"><details${open}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
};
const unplaced=d.unplaced?`<p class="micro" style="color:var(--danger)">Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}</p>`:'';
out.innerHTML=`<ul class="mt-tree">${node(d.root,0)}</ul>${unplaced}`;
}
function renderMatrix(){
const d=matrixUI.data;
const head=`${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · P = Premium, S = Standard`;
document.getElementById('treeToggleBtn').classList.remove('hidden');
document.getElementById('treeToggleBtn').textContent=matrixUI.mode==='pyramid'?'List view':'Pyramid view';
if(matrixUI.mode==='pyramid')renderPyramid();else renderList();
const out=document.getElementById('matrixTree');
out.insertAdjacentHTML('afterbegin',`<p class="micro" style="margin:0 0 10px">${head}</p>`);
}
document.getElementById('treeLoadBtn').addEventListener('click',async()=>{
const out=document.getElementById('matrixTree');
out.innerHTML='<div class="empty">Building tree from the on-chain snapshot…</div>';
out.innerHTML='<div class="empty">Building matrix from the on-chain snapshot…</div>';
try{
const d=await api('/api/admin/matrix-tree');
if(!d.ready||!d.root){out.innerHTML='<div class="empty">Snapshot not ready yet — try again in a minute.</div>';return}
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
const node=(n,depth)=>{
if(!n)return '';
if(n.missing)return `<li class="mt-node"><span class="mt-id">#${n.id}</span> <span style="color:var(--danger)">(no data)</span></li>`;
const badge=n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>';
const kids=n.children&&n.children.length?`<ul class="mt-kids">${n.children.map(c=>node(c,depth+1)).join('')}</ul>`:'';
const open=depth<4?' open':'';
const label=`${badge} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${fmt(n.earnedPol)} POL earned${n.referrerId?` · ref #${n.referrerId}`:''}</span>`;
return n.children&&n.children.length?`<li class="mt-node"><details${open}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
};
const unplaced=d.unplaced?`<p class="micro" style="color:var(--danger)">Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}</p>`:'';
out.innerHTML=`<p class="micro" style="margin:0 0 8px">${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · P = Premium, S = Standard</p><ul class="mt-tree">${node(d.root,0)}</ul>${unplaced}`;
matrixUI.data=d;matrixUI.byId={};matrixUI.parent={};matrixUI.rootId=d.root.id;
mtIndex(d.root,null);
renderMatrix();
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
});
document.getElementById('treeToggleBtn').addEventListener('click',()=>{
matrixUI.mode=matrixUI.mode==='pyramid'?'list':'pyramid';
if(matrixUI.data)renderMatrix();
});
loadState();