feat: add collapsible analytics, click-to-pin chart tooltips, and extended virtualization

This commit is contained in:
matt 2025-10-08 11:38:30 -07:00
parent 3877890889
commit 20b9e8037c
10 changed files with 1036 additions and 202 deletions

View file

@ -798,9 +798,8 @@
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
function initVirtualization(root){
try{
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
// Global diagnostics aggregator
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
var GLOBAL = (function(){
if (!DIAG) return null;
if (window.__virtGlobal) return window.__virtGlobal;
@ -821,7 +820,6 @@
el.style.zIndex = '50';
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
el.style.cursor = 'default';
// Hidden by default; toggle with 'v'
el.style.display = 'none';
document.body.appendChild(el);
store.summaryEl = el;
@ -837,7 +835,7 @@
visible += (g[i].end||0) - (g[i].start||0);
lastMs = Math.max(lastMs, g[i].lastMs||0);
}
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+(lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs))+'ms';
}
function register(gridId, ref){
store.grids.push({ id: gridId, ref: ref });
@ -852,48 +850,66 @@
}
update();
},
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
toggle: function(){
var el = ensure();
el.style.display = (el.style.display === 'none' ? '' : 'none');
}
};
}
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
window.__virtGlobal = {
register: register,
toggle: function(){
var el = ensure();
el.style.display = (el.style.display === 'none' ? '' : 'none');
}
};
return window.__virtGlobal;
})();
// Support card grids and other scroll containers (e.g., #owned-box)
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
var scope = root || document;
if (!scope || !scope.querySelectorAll) return;
var grids = scope.querySelectorAll('[data-virtualize]');
if (!grids.length) return;
grids.forEach(function(grid){
if (grid.__virtBound) return;
grid.__virtBound = true;
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
if (!grid || grid.__virtBound) return;
var attrVal = (grid.getAttribute('data-virtualize') || '').trim();
if (!attrVal || /^0|false$/i.test(attrVal)) return;
var container = grid;
container.style.position = container.style.position || 'relative';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
// Ensure wrapper itself is a grid to preserve multi-column layout inside
// when the container (e.g., .card-grid) is virtualized.
wrapper.style.display = 'grid';
// Move children into a fragment store (for owned, children live under UL)
var mode = attrVal.toLowerCase();
var minItemsAttr = parseInt(grid.getAttribute('data-virtualize-min') || (grid.dataset ? grid.dataset.virtualizeMin : ''), 10);
var rowAttr = parseInt(grid.getAttribute('data-virtualize-row') || (grid.dataset ? grid.dataset.virtualizeRow : ''), 10);
var colAttr = parseInt(grid.getAttribute('data-virtualize-columns') || (grid.dataset ? grid.dataset.virtualizeColumns : ''), 10);
var maxHeightAttr = grid.getAttribute('data-virtualize-max-height') || (grid.dataset ? grid.dataset.virtualizeMaxHeight : '');
var overflowAttr = grid.getAttribute('data-virtualize-overflow') || (grid.dataset ? grid.dataset.virtualizeOverflow : '');
var source = container;
// If this is the owned box, use the UL inside as the source list
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
if (ownedGrid) { source = ownedGrid; }
if (!source || !source.children || !source.children.length) return;
var all = Array.prototype.slice.call(source.children);
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
// is cheaper than the complexity for small sets.
var MIN_VIRT_ITEMS = 80;
if (all.length < MIN_VIRT_ITEMS){
// Mark as processed so we don't attempt again on HTMX swaps.
return; // children remain in place; no virtualization applied.
}
all.forEach(function(node, idx){ try{ node.__virtIndex = idx; }catch(_){ } });
var minItems = !isNaN(minItemsAttr) ? Math.max(0, minItemsAttr) : 80;
if (all.length < minItems) return;
grid.__virtBound = true;
var store = document.createElement('div');
store.style.display = 'none';
all.forEach(function(n){ store.appendChild(n); });
all.forEach(function(node){ store.appendChild(node); });
var padTop = document.createElement('div');
var padBottom = document.createElement('div');
padTop.style.height = '0px'; padBottom.style.height = '0px';
// For owned, keep the UL but render into it; otherwise append wrapper to container
padTop.style.height = '0px';
padBottom.style.height = '0px';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
if (ownedGrid){
ownedGrid.innerHTML = '';
ownedGrid.appendChild(padTop);
@ -901,17 +917,34 @@
ownedGrid.appendChild(padBottom);
ownedGrid.appendChild(store);
} else {
container.appendChild(padTop);
container.appendChild(wrapper);
container.appendChild(padBottom);
container.appendChild(store);
}
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
var perRow = 1;
// Optional diagnostics overlay
if (maxHeightAttr){
container.style.maxHeight = maxHeightAttr;
} else if (!container.style.maxHeight){
container.style.maxHeight = '70vh';
}
if (overflowAttr){
container.style.overflow = overflowAttr;
} else if (!container.style.overflow){
container.style.overflow = 'auto';
}
var baseRow = container.id === 'owned-box' ? 160 : (mode.indexOf('list') > -1 ? 110 : 240);
var minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow;
var rowH = minRowH;
var explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null;
var perRow = explicitCols || 1;
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
var renderCount = 0; var measureCount = 0; var swapCount = 0;
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
function ensureDiag(){
if (!DIAG) return null;
@ -928,8 +961,7 @@
diagBox.style.fontSize = '12px';
diagBox.style.margin = '0 0 .35rem 0';
diagBox.style.color = '#cbd5e1';
diagBox.style.display = 'none'; // hidden until toggled
// Controls
diagBox.style.display = 'none';
var controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.gap = '.35rem';
@ -937,107 +969,204 @@
controls.style.marginBottom = '.25rem';
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
btnCopy.addEventListener('click', function(){ try{ var payload = {
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
btnCopy.addEventListener('click', function(){
try{
var payload = {
id: gridId,
rowH: rowH,
perRow: perRow,
start: start,
end: end,
total: total,
renderCount: renderCount,
measureCount: measureCount,
swapCount: swapCount,
lastRenderMs: lastRenderMs,
lastRenderAt: lastRenderAt,
};
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
btnCopy.textContent = 'Copied';
setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200);
}catch(_){ }
});
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
controls.appendChild(title);
controls.appendChild(btnCopy);
controls.appendChild(btnHide);
diagBox.appendChild(controls);
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
host.insertBefore(diagBox, host.firstChild);
return diagBox;
}
function measure(){
try {
measureCount++;
// create a temp tile to measure if none
var probe = store.firstElementChild || all[0];
if (probe){
var fake = probe.cloneNode(true);
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
fake.style.position = 'absolute';
fake.style.visibility = 'hidden';
fake.style.pointerEvents = 'none';
(ownedGrid || container).appendChild(fake);
var rect = fake.getBoundingClientRect();
rowH = Math.max(120, Math.ceil(rect.height) + 16);
rowH = Math.max(minRowH, Math.ceil(rect.height) + 16);
(ownedGrid || container).removeChild(fake);
}
// Estimate perRow via computed styles of grid
var style = window.getComputedStyle(ownedGrid || container);
var cols = style.getPropertyValue('grid-template-columns');
// Mirror grid settings onto the wrapper so its children still flow in columns
try {
var displayMode = style.getPropertyValue('display');
if (displayMode && displayMode.trim()){
wrapper.style.display = displayMode;
} else if (!wrapper.style.display){
wrapper.style.display = 'grid';
}
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
if (gap && gap.trim()) wrapper.style.gap = gap;
// Inherit justify/align if present
var ji = style.getPropertyValue('justify-items');
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
var ai = style.getPropertyValue('align-items');
if (ai && ai.trim()) wrapper.style.alignItems = ai;
} catch(_) {}
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
} catch(_){}
} catch(_){ }
var derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){
return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);
}).length : 0);
if (explicitCols){
perRow = explicitCols;
} else if (derivedCols){
perRow = Math.max(1, derivedCols);
} else {
perRow = Math.max(1, perRow);
}
} catch(_){ }
}
measure();
var total = all.length;
var start = 0, end = 0;
function render(){
var t0 = DIAG ? performance.now() : 0;
var scroller = container;
var vh = scroller.clientHeight || window.innerHeight;
var scrollTop = scroller.scrollTop;
// If container isnt scrollable, use window scroll offset
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
var newStart = rowStart * perRow;
var newEnd = Math.min(total, rowEnd * perRow);
if (newStart === start && newEnd === end) return; // no change
start = newStart; end = newEnd;
// Padding
var beforeRows = Math.floor(start / perRow);
var afterRows = Math.ceil((total - end) / perRow);
var vh, scrollTop, top;
if (useWindowScroll) {
// Window-scroll mode: measure relative to viewport
vh = window.innerHeight;
var rect = container.getBoundingClientRect();
top = Math.max(0, -rect.top);
scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
} else {
// Container-scroll mode: measure relative to container
vh = scroller.clientHeight || window.innerHeight;
scrollTop = scroller.scrollTop;
top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
}
var rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2;
var rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1);
var rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow)));
var newStart = rowStart * Math.max(1, perRow);
var newEnd = Math.min(total, rowEnd * Math.max(1, perRow));
if (newStart === start && newEnd === end) return;
start = newStart;
end = newEnd;
var beforeRows = Math.floor(start / Math.max(1, perRow));
var afterRows = Math.ceil((total - end) / Math.max(1, perRow));
padTop.style.height = (beforeRows * rowH) + 'px';
padBottom.style.height = (afterRows * rowH) + 'px';
// Render visible children
wrapper.innerHTML = '';
for (var i = start; i < end; i++) {
for (var i = start; i < end; i++){
var node = all[i];
if (node) wrapper.appendChild(node);
}
if (DIAG){
var box = ensureDiag();
if (box){
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
var dt = performance.now() - t0;
lastRenderMs = dt;
renderCount++;
lastRenderAt = Date.now();
var vis = end - start;
var rowsTotal = Math.ceil(total / Math.max(1, perRow));
var textEl = box.querySelector('.virt-diag-text');
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
textEl.textContent = msg;
// Health hint
var bad = (dt > 33) || (vis > 300);
var warn = (!bad) && ((dt > 16) || (vis > 200));
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
if (globalReg && globalReg.set){
globalReg.set({ total: total, start: start, end: end, lastMs: dt });
}
}
}
}
function onScroll(){ render(); }
function onResize(){ measure(); render(); }
container.addEventListener('scroll', onScroll);
// Support both container-scroll (default) and window-scroll modes
var scrollMode = overflowAttr || container.style.overflow || 'auto';
var useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window');
if (useWindowScroll) {
// Window-scroll mode: listen to window scroll events
window.addEventListener('scroll', onScroll, { passive: true });
} else {
// Container-scroll mode: listen to container scroll events
container.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
// Initial size; ensure container is scrollable for our logic
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
container.style.overflow = container.style.overflow || 'auto';
render();
// Re-render after filters resort or HTMX swaps
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
// Keyboard toggle for overlays: 'v'
// Track cleanup for disconnected containers
grid.__virtCleanup = function(){
try {
if (useWindowScroll) {
window.removeEventListener('scroll', onScroll);
} else {
container.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
} catch(_){}
};
document.addEventListener('htmx:afterSwap', function(ev){
if (!container.isConnected) return;
if (!container.contains(ev.target)) return;
swapCount++;
var merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children));
var known = new Map();
all.forEach(function(node, idx){
var index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx;
known.set(node, index);
});
var nextIndex = known.size;
merged.forEach(function(node){
if (!known.has(node)){
node.__virtIndex = nextIndex;
known.set(node, nextIndex);
nextIndex++;
}
});
merged.sort(function(a, b){
var ia = known.get(a);
var ib = known.get(b);
return (ia - ib);
});
merged.forEach(function(node, idx){ node.__virtIndex = idx; });
all = merged;
total = all.length;
measure();
render();
});
if (DIAG && !window.__virtHotkeyBound){
window.__virtHotkeyBound = true;
document.addEventListener('keydown', function(e){
@ -1045,9 +1174,11 @@
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key && e.key.toLowerCase() === 'v'){
e.preventDefault();
// Toggle all virt-diag boxes and the global summary
var shown = null;
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
document.querySelectorAll('.virt-diag').forEach(function(b){
if (shown === null) shown = (b.style.display === 'none');
b.style.display = shown ? '' : 'none';
});
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
}
}catch(_){ }
@ -1198,4 +1329,61 @@
});
}catch(_){ }
});
// --- Lazy-loading analytics accordions ---
function initLazyAccordions(root){
try {
var scope = root || document;
if (!scope || !scope.querySelectorAll) return;
scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){
if (!details || details.__lazyBound) return;
details.__lazyBound = true;
var loaded = false;
details.addEventListener('toggle', function(){
if (!details.open || loaded) return;
loaded = true;
// Mark as loaded to prevent re-initialization
var content = details.querySelector('.analytics-content');
if (!content) return;
// Remove placeholder if present
var placeholder = content.querySelector('.analytics-placeholder');
if (placeholder) {
placeholder.remove();
}
// Content is already rendered in the template, just need to initialize any scripts
// Re-run virtualization if needed
try {
initVirtualization(content);
} catch(_){}
// Re-attach chart interactivity if this is mana overview
var type = details.getAttribute('data-analytics-type');
if (type === 'mana') {
try {
// Tooltip and highlight logic is already in the template scripts
// Just trigger a synthetic event to re-attach if needed
var event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } });
details.dispatchEvent(event);
} catch(_){}
}
// Send telemetry
telemetry.send('analytics.accordion_expand', {
type: type || 'unknown',
accordion: details.id || 'unnamed',
});
});
});
} catch(_){}
}
// Initialize on load and after HTMX swaps
document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(); });
document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); });
})();