/* * Layout helpers (see layout.css). * * Sticky headers: measures the top nav and each table's group header so the column * header rows can stick directly beneath them (--nav-h, --sticky-top, --thead-r1). * * Card view: on phones and tablets each table row is shown as a card, with every cell * rendered as "Column name: value". CSS can't look up a cell's column header, so this * copies each header's text onto its cells as data-label. * * The dashboard re-renders its tables constantly, so both are reapplied (once per frame * at most) whenever the DOM changes. */ (function () { 'use strict'; const cardsQuery = window.matchMedia('(max-width: 1100px)'); const clean = text => text.replace(/[\u2195\u25B2\u25BC\u00D7\u2715]/g, '').replace(/\s+/g, ' ').trim(); function labelTable(table) { const thead = table.tHead; if (!thead) return; // The header row is the one with the most text (Custom View has a drag-handle row above it) let headerRow = null, best = -1; for (const row of thead.rows) { if (row.classList.contains('filter-row')) continue; const named = Array.from(row.cells).filter(c => clean(c.textContent)).length; if (named > best) { best = named; headerRow = row; } } if (!headerRow) return; for (const row of thead.rows) { if (row === headerRow) row.classList.add('card-label-row'); else if (!row.classList.contains('filter-row')) row.classList.add('card-aux-row'); } const labels = Array.from(headerRow.cells).map(c => clean(c.textContent)); const apply = row => { Array.from(row.cells).forEach((cell, i) => { const label = cell.colSpan > 1 ? '' : (labels[i] || ''); if (cell.dataset.label !== label) cell.dataset.label = label; }); }; thead.querySelectorAll('tr.filter-row').forEach(apply); for (const body of table.tBodies) for (const row of body.rows) apply(row); } // --- Sticky offsets --------------------------------------------------------------- function setVar(el, name, value) { if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value); } function groupHeaderFor(table) { // The group header is a sibling of the table or of its wrapper for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) { const gh = el.querySelector(':scope > .group-header'); if (gh) return gh; } return null; } function updateSticky() { const root = document.documentElement; const nav = document.querySelector('.top-nav'); const navH = nav ? nav.getBoundingClientRect().height : 60; setVar(root, '--nav-h', navH + 'px'); const dock = document.getElementById('usmt-term'); setVar(root, '--dock-h', (dock ? Math.ceil(dock.getBoundingClientRect().height) : 0) + 'px'); document.querySelectorAll('table.master-table').forEach(table => { if (!table.offsetParent) return; // hidden page/group: keep old values const gh = groupHeaderFor(table); const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0; // 1px overlap tucks the table header under the group header: no hairline gap const top = Math.max(0, navH + ghH - 1) + 'px'; setVar(table, '--sticky-top', top); if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar }); setupCustomHScroll(); } // Custom View can be wider than the page. A sideways-scrolling box would stop its header // from sticking, so the box clips instead (overflow-x: clip) and the table is shifted // sideways by a scrollbar pinned to the bottom of the window. Wheel, shift+wheel and // touch swipes over the table drive the same scrollbar. function setupCustomHScroll() { const box = document.getElementById('scroll-custom'); const host = document.getElementById('custom-table-host'); const table = document.getElementById('table-custom'); if (!box || !host || !table) return; let bar = document.getElementById('custom-hscroll'); if (!bar) { bar = document.createElement('div'); bar.id = 'custom-hscroll'; bar.className = 'custom-hscroll no-copy'; bar.appendChild(document.createElement('div')); box.insertAdjacentElement('afterend', bar); const applyX = () => { host.style.transform = bar.scrollLeft ? `translateX(${-bar.scrollLeft}px)` : ''; }; bar.addEventListener('scroll', applyX, { passive: true }); box.addEventListener('wheel', e => { if (bar.hidden) return; const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0); if (!dx) return; e.preventDefault(); bar.scrollLeft += dx; }, { passive: false }); let start = null; box.addEventListener('touchstart', e => { if (bar.hidden || e.touches.length !== 1) { start = null; return; } start = { x: e.touches[0].clientX, y: e.touches[0].clientY, left: bar.scrollLeft, lock: null }; }, { passive: true }); box.addEventListener('touchmove', e => { if (!start) return; const dx = e.touches[0].clientX - start.x, dy = e.touches[0].clientY - start.y; if (!start.lock && Math.hypot(dx, dy) > 8) start.lock = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; if (start.lock === 'x') { e.preventDefault(); bar.scrollLeft = start.left - dx; } }, { passive: false }); box.addEventListener('touchend', () => { start = null; }, { passive: true }); } if (cardsQuery.matches || !table.offsetParent) { bar.hidden = true; if (host.style.transform) host.style.transform = ''; return; } const full = Math.ceil(table.getBoundingClientRect().width); const view = box.clientWidth; const inner = bar.firstChild; if (inner.style.width !== full + 'px') inner.style.width = full + 'px'; const hide = full <= view + 1; if (bar.hidden !== hide) bar.hidden = hide; if (hide && bar.scrollLeft) { bar.scrollLeft = 0; host.style.transform = ''; } } // Auto-sizing text boxes (Workbench stacks) only grow while typing in the original; // size them to their contents whenever they're shown or re-rendered too. function autosizeTextareas() { document.querySelectorAll('textarea.auto-resize').forEach(t => { if (!t.offsetParent || document.activeElement === t) return; if (Math.abs(t.scrollHeight - t.clientHeight) <= 2) return; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; }); } // Custom View column drag (desktop). Grab a column by its grip or anywhere on its header // (a plain click still sorts). While dragging, the real column slides into the place it // would land, drawn as a translucent ghost, and a label follows the pointer. Only the // pointer's horizontal position matters: passing a column's middle moves the ghost past // it. Release to drop, Esc to cancel. Replaces the browser's native drag-and-drop. const colDrag = { active: null, pendingRender: false }; function setupColumnDrag() { const table = document.getElementById('table-custom'); if (!table || table.dataset.pdrag) return; table.dataset.pdrag = '1'; // Native drag-and-drop on the grips is replaced by the pointer drag below table.addEventListener('dragstart', e => { if (e.target.closest && e.target.closest('thead')) e.preventDefault(); }, true); table.addEventListener('pointerdown', e => { if (e.button !== 0 || cardsQuery.matches || colDrag.active) return; const th = e.target.closest('thead th.custom-th'); if (!th || e.target.closest('.th-icons') || e.target.closest('.col-resize-handle')) return; const headers = Array.from(th.parentElement.cells); const from = headers.indexOf(th); if (from < 0) return; const st = { th, from, pos: from, x0: e.clientX, y0: e.clientY, started: false, cells: null, float: null }; colDrag.active = st; const move = ev => onDragMove(ev, st, table); const up = ev => { finish(ev, st, table, false); }; const key = ev => { if (ev.key === 'Escape' && st.started) finish(ev, st, table, true); }; st.cleanup = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up); window.removeEventListener('keydown', key, true); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); window.addEventListener('pointercancel', up); window.addEventListener('keydown', key, true); }); } function onDragMove(e, st, table) { if (!st.started) { if (Math.hypot(e.clientX - st.x0, e.clientY - st.y0) < 6) return; st.started = true; st.cells = Array.from(table.rows).map(r => r.cells[st.from]).filter(Boolean); st.cells.forEach(c => c.classList.add('col-ghost')); st.float = document.createElement('div'); st.float.className = 'col-drag-float'; const label = st.th.querySelector('.label-text'); st.float.textContent = label ? label.textContent : st.th.textContent.trim(); document.body.appendChild(st.float); document.body.classList.add('col-dragging'); const overlay = document.getElementById('custom-resize-overlay'); if (overlay) overlay.style.visibility = 'hidden'; } e.preventDefault(); st.float.style.left = e.clientX + 'px'; st.float.style.top = e.clientY + 'px'; // Nudge the sideways scrollbar when dragging near the edges of a wide table const bar = document.getElementById('custom-hscroll'); const box = document.getElementById('scroll-custom'); if (bar && !bar.hidden && box) { const r = box.getBoundingClientRect(); if (e.clientX < r.left + 40) bar.scrollLeft -= 18; else if (e.clientX > r.right - 40) bar.scrollLeft += 18; } // Target slot = number of other columns whose middle is left of the pointer const headerCells = Array.from(st.th.parentElement.cells).filter(c => c !== st.th); let pos = 0; for (const c of headerCells) { const b = c.getBoundingClientRect(); if (e.clientX > b.left + b.width / 2) pos++; } if (pos === st.pos) return; st.pos = pos; Array.from(table.rows).forEach((row, i) => { const cell = st.cells[i]; if (!cell || cell.parentElement !== row) return; const others = Array.from(row.cells).filter(c => c !== cell); row.insertBefore(cell, others[pos] || null); }); } function finish(e, st, table, cancelled) { st.cleanup(); colDrag.active = null; if (!st.started) return; // a click: let it sort as usual // swallow the click that follows a drag so it doesn't also sort const stop = ev => { ev.stopPropagation(); ev.preventDefault(); }; window.addEventListener('click', stop, true); setTimeout(() => window.removeEventListener('click', stop, true), 0); st.cells.forEach(c => c.classList.remove('col-ghost')); if (st.float) st.float.remove(); document.body.classList.remove('col-dragging'); const overlay = document.getElementById('custom-resize-overlay'); if (overlay) overlay.style.visibility = ''; if (!cancelled && st.pos !== st.from) moveCustomColumn(st.from, st.pos); // re-renders in the new order else if (st.pos !== st.from || colDrag.pendingRender) renderCustomTable(); // put cells back colDrag.pendingRender = false; } let queued = false; function run() { queued = false; updateSticky(); autosizeTextareas(); if (!cardsQuery.matches) return; document.querySelectorAll('table.master-table').forEach(labelTable); } function schedule() { if (queued) return; queued = true; requestAnimationFrame(run); } function start() { new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule); else cardsQuery.addListener(schedule); window.addEventListener('resize', schedule); // Groups and pages are shown by toggling display (not a DOM change), so also // re-measure as the user scrolls; writes only happen when a value changes. window.addEventListener('scroll', schedule, { passive: true }); // Custom View column drag; a data refresh mid-drag waits until the drop setupColumnDrag(); if (typeof window.renderCustomTable === 'function') { const origRender = window.renderCustomTable; window.renderCustomTable = function () { if (colDrag.active && colDrag.active.started) { colDrag.pendingRender = true; return; } return origRender.apply(this, arguments); }; } // Page switches show and hide pages without changing the DOM if (typeof window.switchPage === 'function') { const orig = window.switchPage; window.switchPage = function () { const r = orig.apply(this, arguments); schedule(); return r; }; } // The terminal dock opens, closes and resizes without a page resize if (window.ResizeObserver) { const watchDock = () => { const dock = document.getElementById('usmt-term'); if (dock) new ResizeObserver(schedule).observe(dock); else setTimeout(watchDock, 500); }; watchDock(); } schedule(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start(); })();