Private/Get-DhJsTable.ps1
|
function Get-DhJsTable { # Auto-split fragment of the dashboard runtime JS (see Get-DhJsContent). return @' /* ========================================================================= TABLE ENGINE ========================================================================= */ /* Footer aggregate of the numbers in a column. `rowCount` feeds 'count'. null = nothing to show. */ function dhAggregate(agg, nums, rowCount) { switch (agg) { case 'sum': return nums.reduce(function(a,b){return a+b;}, 0); case 'avg': return nums.length ? nums.reduce(function(a,b){return a+b;},0)/nums.length : null; case 'min': return nums.length ? Math.min.apply(null, nums) : null; case 'max': return nums.length ? Math.max.apply(null, nums) : null; case 'count': return rowCount; } return null; } /* Footer cell text. An unformatted column would show 37.333333333: round to Decimals (default 2). */ function dhFootText(col, result) { var plain = !col.format || String(col.format).toLowerCase() === 'text'; return (plain && typeof result === 'number' && isFinite(result) && result % 1 !== 0) ? result.toFixed(col.decimals >= 0 ? col.decimals : 2) : (FMT.apply(col, result) || String(result)); } /* v2.5.2 baseline markers (Add-DhTable -Baseline): pure helpers */ function dhBaselineNote(b) { return 'vs ' + b.label + ': +' + b['new'] + ' new, ~' + b.changed + ' changed, \u2212' + b.removed + ' removed'; } /* filter: '' (all) | 'n' (new) | 'c' (changed); state is the row's entry in baseline.changes, or undefined */ function dhChangeKeep(state, filter) { return !filter || (!!state && state.s === filter); } /* Text of a cell as the reader sees it, without the New / Changed tag (copy cell). */ function dhCellText(td) { var c = td.cloneNode(true); var tags = c.querySelectorAll('.chg-tag'); for (var i = 0; i < tags.length; i++) tags[i].parentNode.removeChild(tags[i]); return (c.textContent || '').trim(); } /* CSV formula guard: a spreadsheet evaluates a cell that starts with = + - @ (or a tab / CR), so such text gets a leading apostrophe. Numbers such as -5 or +1.5 are left alone. */ function dhCsvSafe(v) { var s = (v === null || v === undefined) ? '' : String(v); if (/^[=+\-@\t\r]/.test(s) && !(s.trim() !== '' && isFinite(Number(s)))) return "'" + s; return s; } function dhWasText(col, was, fmtApply) { return 'was: ' + ((was === null || was === undefined || was === '') ? '(empty)' : (fmtApply(col, was) || String(was))); } function TableEngine(cfg) { this.id = cfg.id; this.title = cfg.title||cfg.id; this.allData = cfg.data.slice(); /* v2.1.4 — row -> position in allData, built once. Every row render used to call allData.indexOf(row), a linear scan per row: O(n^2) per render on a large page or virtual window. allData is never reordered. */ this._rowIdx = new Map(); for (var ri = 0; ri < this.allData.length; ri++) this._rowIdx.set(this.allData[ri], ri); this.columns = cfg.columns; this.pageSize = cfg.pageSize||15; this.multiSelect = cfg.multiSelect||false; this.filterable = cfg.filterable!==false; this.pageable = cfg.pageable!==false; this.outLinks = cfg.outLinks||[]; this.charts = cfg.charts||[]; this.detail = cfg.detail||null; /* v2.3.0 - row detail drawer (Add-DhTable -DetailDrawer) */ /* v2.5.2 - baseline markers: row object -> {s:'n'|'c', f:[fields], w:{old values}}; changeFilter '' | 'n' | 'c' */ this.base = cfg.baseline||null; this.changeFilter = ''; this._includeRemoved = false; this._baseState = new Map(); if (this.base) { for (var bk in this.base.changes) { if (Object.prototype.hasOwnProperty.call(this.base.changes, bk) && this.allData[+bk]) this._baseState.set(this.allData[+bk], this.base.changes[bk]); } } this.currentPage = 1; this.sortFields = []; /* [{field, dir}] — multi-sort */ this.filterText = ''; this.linkedFilter = null; this.pathFilter = null; /* v2.4.0 - set only through setPathFilter (Add-DhTreeFilter) */ this.selected = []; this.colVisible = {}; /* v2.5.0 - a column with Hidden starts unticked in the picker; -RememberColumns restores the reader's own choice */ this.columns.forEach(function(col,i){this.colVisible[i]=!col.hidden;},this); this.rememberKey = cfg.remember||''; this._restoreCols(); /* Per-field width overrides set by dragging a header edge. Empty until the user resizes something; the declared col.width stays the default. */ this.colWidths = {}; /* v2.4.0 - measured column widths (Add-DhTable -ColumnFit Auto, the default). Precedence: dragged width, then the author's Width, then this. */ this.columnFit = (cfg.columnFit === 'fixed') ? 'fixed' : 'auto'; this.columnMax = cfg.columnMax || 800; this._fitW = null; this.exportFileName = cfg.exportFileName || cfg.id; /* v2.3.0 - AllClearText: an empty check shows a check mark instead of an empty grid */ if(cfg.allClear && !this.allData.length){ var acSec=document.getElementById('section-'+cfg.id); var acBar=acSec&&acSec.querySelector('.table-toolbar'); if(acBar){ acSec.classList.add('table-allclear'); var acCard=document.createElement('div'); acCard.className='table-allclear-card'; acCard.innerHTML=allClearHtml(cfg.allClear); acBar.parentNode.insertBefore(acCard,acBar); } } /* v2.6.0 - New-DhDashboard -LinkState: sort, page size and Change chip from the address before the first render */ this.linkState = !!cfg.linkState; this._pageSize0 = this.pageSize; this._pendingPage = 0; this._restoreLinkState(); this._initPageSizes(); this._bindFilter(); this._bindClearSel(); this._bindLinkClear(); this._bindExport(); this._buildColToggle(); this._renderCharts(); this._bindRightClick(); this._buildChangeBar(); this.render(); } /* ── Table state in the address (v2.6.0, New-DhDashboard -LinkState) ── Only what differs from the default is written: s = [[field, 'asc'|'desc'], ...], p = page (> 1), z = page size (when changed), c = Change chip ('n' | 'c'). The text filter is already kept under "filters". Reading is defensive: unknown or unsortable columns, odd page sizes and other keys are ignored, and a page past the end is clamped once every filter has been restored. */ var DH_LS_CAP = 1500, dhLsSeq = 0; var DH_PAGE_SIZES = [5, 10, 15, 25, 50, 100]; function dhTableStateGet(eng) { var st = {}; if (eng.sortFields.length) st.s = eng.sortFields.map(function (sf) { return [sf.field, sf.dir]; }); if (eng.pageable && eng.currentPage > 1) st.p = eng.currentPage; if (eng.pageSize !== eng._pageSize0) st.z = eng.pageSize; if (eng.changeFilter) st.c = eng.changeFilter; return Object.keys(st).length ? st : null; } function dhTableStateSet(eng, st) { if (!st || typeof st !== 'object') return; var sortable = Object.create(null); eng.columns.forEach(function (c) { if (c.sortable !== false) sortable[c.field] = true; }); if (Array.isArray(st.s)) { var sf = []; st.s.forEach(function (x) { if (!Array.isArray(x) || typeof x[0] !== 'string' || sortable[x[0]] !== true) return; if (x[1] !== 'asc' && x[1] !== 'desc') return; if (sf.some(function (y) { return y.field === x[0]; })) return; sf.push({ field: x[0], dir: x[1] }); }); eng.sortFields = sf; } var z = parseInt(st.z, 10); if (DH_PAGE_SIZES.indexOf(z) !== -1) eng.pageSize = z; var p = parseInt(st.p, 10); if (eng.pageable && p > 1) eng._pendingPage = p; if ((st.c === 'n' || st.c === 'c') && eng.base) eng.changeFilter = st.c; } /* Every -LinkState table's state; past DH_LS_CAP characters the least recently used tables are dropped. */ function dhTableStateAll(engs) { var list = []; Object.keys(engs).forEach(function (id) { var e = engs[id]; if (!e.linkState) return; var st = dhTableStateGet(e); if (st) list.push({ id: id, st: st, touch: e._lsTouch || 0 }); }); if (!list.length) return null; list.sort(function (a, b) { return b.touch - a.touch; }); var out = {}, size = 2, n = 0; list.forEach(function (x) { var add = JSON.stringify(x.id).length + JSON.stringify(x.st).length + 2; if (size + add <= DH_LS_CAP) { out[x.id] = x.st; size += add; n++; } }); return n ? out : null; } /* The page from the address, applied after every other filter (card, tree, global) was restored. */ function dhTableStateFinish(engs) { Object.keys(engs).forEach(function (id) { var e = engs[id], p = e._pendingPage; if (!p) return; e._pendingPage = 0; var tp = Math.max(1, Math.ceil(e._getFiltered().length / e.pageSize)); e.currentPage = Math.min(p, tp); e.render(); }); } TableEngine.prototype._restoreLinkState = function () { if (!this.linkState) return; var t = URLState.load().t; if (t && typeof t === 'object' && Object.prototype.hasOwnProperty.call(t, this.id)) dhTableStateSet(this, t[this.id]); }; TableEngine.prototype._saveLinkState = function () { if (!this.linkState) return; this._lsTouch = ++dhLsSeq; URLState.save(); }; /* ── Baseline markers (v2.5.2) ─────────────────────────────────────── A line under the toolbar ("vs previous run: +3 new, ~2 changed, -1 removed"), Change chips that filter the table, and a collapsed group under the table for the rows that disappeared. */ TableEngine.prototype._buildChangeBar = function () { if (!this.base) return; var self = this, b = this.base, tb = document.getElementById('toolbar-' + this.id); if (!tb) return; var bar = document.createElement('div'); bar.className = 'tbl-change-bar'; bar.id = 'chg-bar-' + this.id; var defs = [['', 'All', this.allData.length], ['n', 'New', b['new']], ['c', 'Changed', b.changed], ['r', 'Removed', b.removed]]; var html = '<span class="tbl-baseline-note">' + esc(dhBaselineNote(b)) + '</span>' + '<span class="chg-chips" role="group" aria-label="Filter by change">'; defs.forEach(function (d) { html += '<button type="button" class="chg-chip" data-chg="' + d[0] + '" aria-pressed="' + (d[0] === self.changeFilter ? 'true' : 'false') + '">' + esc(d[1]) + ' <span class="chg-n">' + d[2] + '</span></button>'; }); html += '</span>'; if (b.removed) html += '<label class="chg-incl"><input type="checkbox" class="chg-incl-box"> Include removed rows in exports</label>'; bar.innerHTML = html; tb.parentNode.insertBefore(bar, tb.nextSibling); bar.addEventListener('click', function (e) { var chip = e.target.closest ? e.target.closest('.chg-chip') : null; if (!chip) return; var v = chip.getAttribute('data-chg'); if (v === 'r') { /* Removed rows are not in the table: reveal their group */ if (self._remBox) { self._remBox.open = true; self._remBox.scrollIntoView({ block: 'nearest' }); } return; } self.changeFilter = (self.changeFilter === v) ? '' : v; bar.querySelectorAll('.chg-chip').forEach(function (c) { var cv = c.getAttribute('data-chg'); if (cv === 'r') return; c.setAttribute('aria-pressed', (cv === self.changeFilter) ? 'true' : 'false'); }); self.currentPage = 1; self.render(); self._refreshLinkedBarCharts(); self._renderCharts(); self._saveLinkState(); }); var box = bar.querySelector('.chg-incl-box'); if (box) box.addEventListener('change', function () { self._includeRemoved = box.checked; }); if (b.removed) { var tbl = document.getElementById('tbl-' + this.id), wrap = tbl && tbl.closest ? tbl.closest('.table-wrapper') : null; var det = document.createElement('details'); det.className = 'tbl-removed'; det.id = 'chg-removed-' + this.id; det.innerHTML = '<summary>Removed since ' + esc(b.label) + ' (<span class="tbl-removed-n">' + b.removed + '</span>)</summary><div class="tbl-removed-body"></div>'; if (wrap && wrap.parentNode) wrap.parentNode.insertBefore(det, wrap.nextSibling); else bar.parentNode.appendChild(det); this._remBox = det; this._remBody = det.querySelector('.tbl-removed-body'); /* 2.5.3: the rows are drawn only while the group is open (it used to be rebuilt on every render) */ det.addEventListener('toggle', function () { if (det.open) self._renderRemoved(); }); } }; TableEngine.prototype._renderRemoved = function () { if (!this.base || !this._remBody) return; var self = this, cols = this._visibleCols(), term = (this.filterText || '').toLowerCase(); var rows = this.base.removedRows.filter(function (r) { return !term || self._matchText(r, term, cols); }); var n = this._remBox.querySelector('.tbl-removed-n'); if (n) n.textContent = rows.length; if (!this._remBox.open) { this._remBody.innerHTML = ''; return; } /* the count stays current; the rows wait for the open */ var h = '<table class="tbl-removed-table"><thead><tr>' + cols.map(function (c) { return '<th scope="col">' + esc(c.label) + '</th>'; }).join('') + '</tr></thead><tbody>'; rows.forEach(function (r) { h += '<tr>' + cols.map(function (c) { var v = r[c.field]; return '<td>' + esc(v === null || v === undefined ? '' : (FMT.apply(c, v) || String(v))) + '</td>'; }).join('') + '</tr>'; }); if (!rows.length) h += '<tr><td colspan="' + Math.max(1, cols.length) + '" class="tbl-removed-none">No removed row matches the filter.</td></tr>'; this._remBody.innerHTML = h + '</tbody></table>'; }; /* ── Card filter bridge ─────────────────────────────────────────── */ TableEngine.prototype._applyCardFilters = function () { this.currentPage = 1; this.render(); this._refreshLinkedBarCharts(); this._renderCharts(); }; /* Re-render any bar charts, Mode-A pie charts and range bars that reference this table. v2.1.1 - every branch resolves its target through blockRenderTarget so the collapsible chrome survives a filter change. */ TableEngine.prototype._refreshLinkedBarCharts = function () { var id = this.id; BLOCKS_CONFIG.forEach(function (block) { if (block.blockType === 'barchart' && block.tableId === id) { var bcT = blockRenderTarget(block); if (bcT.container) renderBarChart(bcT.container, bcT.block); } else if (block.blockType === 'piechart' && block.mode === 'fromtable' && block.tableId === id) { var pcT = blockRenderTarget(block); if (pcT.container) renderPieChart(pcT.container, pcT.block); } else if (block.blockType === 'rangebar' && block.mode === 'fromtable' && block.tableId === id) { /* v2.1.0 - a filtered table live-recomputes its range bars */ var rbT = blockRenderTarget(block); if (rbT.container) renderRangeBar(rbT.container, rbT.block); } }); }; /* Position of a row object in allData (-1 when it is not one of ours). */ TableEngine.prototype._idxOf = function (row) { var i = this._rowIdx.get(row); return (i === undefined) ? -1 : i; }; /* ── Visible columns ────────────────────────────────────────────── */ TableEngine.prototype._visibleCols = function () { var self=this; return this.columns.filter(function(col,i){return self.colVisible[i]!==false;}); }; /* ── Page size ──────────────────────────────────────────────────── */ TableEngine.prototype._initPageSizes = function () { var sel=document.getElementById('pagesize-'+this.id); if (!sel) return; var self=this; [5,10,15,25,50,100].forEach(function(n){ var o=document.createElement('option'); o.value=n;o.textContent=n; if(n===self.pageSize)o.selected=true; sel.appendChild(o); }); sel.addEventListener('change',function(){self.pageSize=parseInt(sel.value,10);self.currentPage=1;self.render();self._saveLinkState();}); if (!this.filterable){var fw=document.getElementById('filter-wrap-'+this.id);if(fw)fw.style.display='none';} }; /* ── Filter ─────────────────────────────────────────────────────── */ /* v2.1.3 — the single definition of "apply a text filter to this table". The search box, pie-slice clicks and bar-chart clicks all route through here. Before 2.1.3 each carried its own partial copy and they had drifted: the pie never revealed the clear button and never refreshed linked charts, and the bar chart's inline onclick did neither and skipped URLState too. Add a step here, never in a caller. */ TableEngine.prototype.applyTextFilter = function (value) { var inp = document.getElementById('filter-'+this.id); var clr = document.getElementById('filter-clear-'+this.id); this.filterText = value || ''; this.currentPage = 1; if (inp) inp.value = this.filterText; if (clr) clr.style.display = this.filterText ? 'flex' : 'none'; this.render(); this._refreshLinkedBarCharts(); this._renderCharts(); URLState.save(); }; TableEngine.prototype._bindFilter = function () { var inp=document.getElementById('filter-'+this.id); var clr=document.getElementById('filter-clear-'+this.id); if (!inp) return; var self=this; inp.addEventListener('input',function(){ self.applyTextFilter(inp.value); }); clr.addEventListener('click',function(){ self.applyTextFilter(''); }); /* Restore from URL */ var saved=URLState.load(); if (saved.filters&&saved.filters[this.id]) { inp.value=saved.filters[this.id]; self.filterText=inp.value; clr.style.display='flex'; } }; /* ── Clear selection ────────────────────────────────────────────── */ TableEngine.prototype._bindClearSel = function () { var btn=document.getElementById('clear-sel-'+this.id); if (!btn) return; var self=this; btn.addEventListener('click',function(){self.selected=[];self._notifyLinks(null);self.render();}); }; /* ── Link clear ─────────────────────────────────────────────────── */ TableEngine.prototype._bindLinkClear = function () { var btn=document.getElementById('link-clear-'+this.id); if (!btn) return; var self=this; btn.addEventListener('click',function(){ self.linkedFilter=null;self.currentPage=1;self.selected=[]; var badge=document.getElementById('link-badge-'+self.id); if(badge)badge.style.display='none'; if(self.pathFilter){self.setPathFilter(null);return;} self.render(); }); }; /* ── Export bindings ────────────────────────────────────────────── */ TableEngine.prototype._bindExport = function () { var self=this; var csv=document.getElementById('exp-csv-'+this.id); var xlsx=document.getElementById('exp-xlsx-'+this.id); var pdf=document.getElementById('exp-pdf-'+this.id); if(csv) csv.addEventListener('click',function(){self.exportCsv();}); if(xlsx)xlsx.addEventListener('click',function(){self.exportExcel();}); if(pdf) pdf.addEventListener('click',function(){self.exportPdf();}); }; /* ── Right-click copy ───────────────────────────────────────────── */ TableEngine.prototype._bindRightClick = function () { var tbody = document.getElementById('tbody-' + this.id); if (!tbody) return; var self = this; /* Custom context menu element — one per table. v2.1.4: the items carry classes, not ids; with fixed ids every table after the first put a duplicate id into the document. */ var ctxMenu = document.createElement('div'); ctxMenu.className = 'ctx-menu'; ctxMenu.style.display = 'none'; ctxMenu.innerHTML = '<div class="ctx-item ctx-copy-cell">📋 Copy cell value</div>' + '<div class="ctx-item ctx-copy-csv">💾 Copy table as CSV</div>'; document.body.appendChild(ctxMenu); /* Clipboard write with a fallback for file:// pages and browsers where navigator.clipboard is missing or refuses (it needs a secure context). */ function copyText(text, onDone) { function legacy() { var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0;'; document.body.appendChild(ta); ta.select(); var ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } document.body.removeChild(ta); if (ok && onDone) onDone(); } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { if (onDone) onDone(); }, legacy); } else { legacy(); } } var targetTd = null; function closeMenu() { ctxMenu.style.display = 'none'; targetTd = null; } document.addEventListener('click', closeMenu); document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeMenu(); }); tbody.addEventListener('contextmenu', function(e) { var td = e.target.closest('td'); if (!td) return; targetTd = td; ctxMenu.style.cssText = 'display:block;position:fixed;top:'+e.clientY+'px;left:'+e.clientX+'px;z-index:9999;'; e.preventDefault(); }); /* Copy single cell */ ctxMenu.querySelector('.ctx-copy-cell').addEventListener('click', function() { if (!targetTd) return; var td = targetTd; var text = dhCellText(td); if (text) { copyText(text, function() { td.classList.add('cell-copied'); setTimeout(function(){ td.classList.remove('cell-copied'); }, 600); }); } closeMenu(); }); /* Copy entire filtered table as CSV */ ctxMenu.querySelector('.ctx-copy-csv').addEventListener('click', function() { var r = self._exportRows(); var lines = [r.cols.map(function(c){ return '"'+c.label.replace(/"/g,'""')+'"'; }).join(',')]; r.rows.forEach(function(row){ lines.push(r.cols.map(function(c){ var v = dhCsvSafe(FMT.forExport(c, row[c.field])); return '"'+v.replace(/"/g,'""')+'"'; }).join(',')); }); copyText(lines.join('\r\n'), function(){ /* Brief visual feedback on the table */ var tbl = document.getElementById('tbl-'+self.id); if (tbl) { tbl.classList.add('table-copied'); setTimeout(function(){ tbl.classList.remove('table-copied'); }, 800); } }); closeMenu(); }); }; /* ── Column visibility toggle ───────────────────────────────────── */ /* v2.5.0 - the reader's column choice, kept per viewer in localStorage (opt-in: Add-DhTable -RememberColumns). Stored by field name, so a column added later keeps its default. Storage can be blocked (private windows), so every access is guarded and the table works without it. */ TableEngine.prototype._colsKey = function () { return this.rememberKey ? 'dh:cols:'+this.rememberKey : ''; }; TableEngine.prototype._restoreCols = function () { var key=this._colsKey(); if(!key) return; try{ var saved=JSON.parse(window.localStorage.getItem(key)||'null'); if(!saved||typeof saved!=='object') return; var self=this; this.columns.forEach(function(col,i){ if(Object.prototype.hasOwnProperty.call(saved,col.field)) self.colVisible[i]=!!saved[col.field]; }); }catch(e){} }; TableEngine.prototype._saveCols = function () { var key=this._colsKey(); if(!key) return; try{ var map={}, self=this; this.columns.forEach(function(col,i){ map[col.field]=self.colVisible[i]!==false; }); window.localStorage.setItem(key,JSON.stringify(map)); }catch(e){} }; TableEngine.prototype._colToggleLabel = function () { var btn=document.getElementById('btn-col-toggle-'+this.id); if(!btn) return; var self=this, n=0; this.columns.forEach(function(col,i){ if(self.colVisible[i]===false) n++; }); btn.innerHTML='☰ Columns'+(n?' ('+n+' hidden)':''); }; TableEngine.prototype._buildColToggle = function () { var btn=document.getElementById('btn-col-toggle-'+this.id); var dd=document.getElementById('col-toggle-dd-'+this.id); if(!btn||!dd) return; var self=this, boxes=[]; this.columns.forEach(function(col,i){ var row=document.createElement('label'); row.className='col-toggle-item'; var chk=document.createElement('input'); chk.type='checkbox';chk.checked=self.colVisible[i]!==false; chk.addEventListener('change',function(){self.colVisible[i]=chk.checked;self._saveCols();self._colToggleLabel();self.render();}); boxes.push(chk); row.appendChild(chk); row.appendChild(document.createTextNode(' '+col.label)); dd.appendChild(row); }); /* back to the author's columns (and forget the saved choice) */ var reset=document.createElement('button'); reset.type='button'; reset.className='col-toggle-reset'; reset.textContent='Default columns'; reset.addEventListener('click',function(e){ e.stopPropagation(); self.columns.forEach(function(col,i){ self.colVisible[i]=!col.hidden; boxes[i].checked=!col.hidden; }); var key=self._colsKey(); if(key){ try{ window.localStorage.removeItem(key); }catch(x){} } self._colToggleLabel(); self.render(); }); dd.appendChild(reset); this._colToggleLabel(); btn.addEventListener('click',function(e){e.stopPropagation();dd.style.display=dd.style.display==='none'?'block':'none';}); document.addEventListener('click',function(){dd.style.display='none';}); }; /* ── Linked-filter API ──────────────────────────────────────────── */ TableEngine.prototype.applyLinkedFilter = function (field,value,masterTitle) { this.linkedFilter=(value!==null)?{field:field,value:String(value),masterTitle:masterTitle}:null; this.currentPage=1;this.selected=[]; this._updateLinkBadge();this.render(); }; TableEngine.prototype._updateLinkBadge = function () { var badge=document.getElementById('link-badge-'+this.id); var text=document.getElementById('link-text-'+this.id); if(!badge)return; if(this.linkedFilter){ badge.style.display='flex'; text.textContent='Filtered by \u201C'+(this.linkedFilter.masterTitle||'master')+ '\u201D \u2192 '+this.linkedFilter.field+' = '+this.linkedFilter.value; } else if(this.pathFilter){ badge.style.display='flex'; text.textContent='Filtered by \u201C'+(this.pathFilter.title||'tree')+ '\u201D \u2192 '+(this.pathFilter.label||this.pathFilter.value); } else {badge.style.display='none';} }; /* v2.4.0 - the single definition of "this row matches this text" (lower-cased term). _getFiltered and the report-wide search both call it. */ TableEngine.prototype._matchText = function (row, term, cols) { cols = cols || this._visibleCols(); for (var i = 0; i < cols.length; i++) { var col = cols[i], v = row[col.field]; if (v === null || v === undefined) continue; if (String(v).toLowerCase().indexOf(term) !== -1 || FMT.apply(col, v).toLowerCase().indexOf(term) !== -1) return true; } return false; }; /* v2.4.0 - path filter (Add-DhTreeFilter). spec = {field, value, sep, mode, nodeId, label, title} or null. The one place pathFilter is assigned, like applyTextFilter for text: callers never set it directly. */ TableEngine.prototype.setPathFilter = function (spec) { this.pathFilter = spec || null; this.currentPage = 1; this.selected = []; this._updateLinkBadge(); this.render(); this._refreshLinkedBarCharts(); this._renderCharts(); if (typeof TreeFilter !== 'undefined') TreeFilter.sync(this.id); URLState.save(); }; /* ── Data pipeline — multi-sort + card filters ──────────────────── */ TableEngine.prototype._getFiltered = function () { var data=this.allData;var self=this; if (self.linkedFilter) { var lf=self.linkedFilter; data=data.filter(function(row){return String(row[lf.field])===lf.value;}); } if (self.base && self.changeFilter) { data=data.filter(function(row){return dhChangeKeep(self._baseState.get(row), self.changeFilter);}); } /* Card filters */ Object.keys(cardFilters).forEach(function(filterId) { var cf=cardFilters[filterId]; if (cf.values&&cf.values.length) { data=data.filter(function(row){ return cf.values.some(function(v){return String(row[cf.field])===v;}); }); } }); /* Global page-level filters (v1.9.0) — only rows matching every active facet / date-range whose field this table has. */ if (typeof GlobalFilter !== 'undefined' && GlobalFilter.active()) { data=data.filter(function(row){ return GlobalFilter.matchRow(row); }); } if (self.pathFilter) { var pf=self.pathFilter; data=data.filter(function(row){return dhPathMatch(row[pf.field],pf.value,pf.sep,pf.mode);}); } if (self.filterText) { var term=self.filterText.toLowerCase(); var mcols=self._visibleCols(); data=data.filter(function(row){return self._matchText(row,term,mcols);}); } /* Multi-sort: sortFields is [{field,dir},...] */ if (self.sortFields&&self.sortFields.length) { /* Pre-build a field → column map so we can spot sparkline columns without scanning self.columns inside the hot comparator loop. */ var colMap = {}; (self.columns||[]).forEach(function(c){ colMap[c.field] = c; }); /* PERF 1.10.4 — decorate/sort/undecorate. The previous comparator ran parseFloat twice AND String.localeCompare(x, undefined, {sensitivity:'base'}) on every comparison (~n log n: ~270,000 calls for a 19,011-row table). Passing an options object to localeCompare makes the engine resolve collation settings per call, which measured 5.6 s of BLOCKED main thread to sort one column. Precomputing each row's keys once (O(n)) and reusing a single Intl.Collator is ~32x faster. Ordering is byte-for-byte unchanged: the spec defines localeCompare(y, loc, opts) as new Intl.Collator(loc, opts).compare(x, y), and Array#sort is stable in ES2019+, so ties keep their input order. */ var _ic = (typeof Intl !== 'undefined' && Intl.Collator) ? new Intl.Collator(undefined, {sensitivity:'base'}) : null; var cmpStr = _ic ? function(x,y){ return _ic.compare(x,y); } : function(x,y){ return x.localeCompare(y,undefined,{sensitivity:'base'}); }; var sfs=self.sortFields, nf=sfs.length; var dec=new Array(data.length); for (var di=0; di<data.length; di++) { var row=data[di], ks=new Array(nf), ns=new Array(nf); for (var fi=0; fi<nf; fi++) { var f=sfs[fi].field, col=colMap[f]; var v=(row[f]!=null)?row[f]:''; /* v1.4 F8 — sparkline columns reduce arrays to a comparable scalar */ if (col && col.cellType === 'sparkline') { v = sparkReduce(v, col.sortBy); } var num=parseFloat(v); ks[fi]=String(v); ns[fi]=isNaN(num)?null:num; } dec[di]={r:row,k:ks,n:ns}; } dec.sort(function(a,b){ for (var i=0;i<nf;i++) { var an=a.n[i],bn=b.n[i]; var n=(an!==null&&bn!==null)?(an-bn):cmpStr(a.k[i],b.k[i]); if(n!==0)return sfs[i].dir==='asc'?n:-n; } return 0; }); data=new Array(dec.length); for (var ui=0; ui<dec.length; ui++) { data[ui]=dec[ui].r; } } return data; }; /* ── Cell rendering ─────────────────────────────────────────────── */ TableEngine.prototype._renderCell = function (col,rawVal,row) { var td=document.createElement('td'); var cellType=(col.cellType||'text').toLowerCase(); var isSparklineCell = (cellType === 'sparkline'); var strRaw=(rawVal!==null&&rawVal!==undefined)?String(rawVal):''; /* Skip FMT.apply + threshold matching for sparkline cells — the value is an array, not a scalar, and those code paths would mis-parse it. */ var dispVal = isSparklineCell ? '' : (FMT.apply(col,rawVal) || strRaw); var thClass = isSparklineCell ? '' : getThresholdClass(col,rawVal); var align=(col.align||'left').toLowerCase(); if(align==='right') td.classList.add('cell-right'); if(align==='center')td.classList.add('cell-center'); if(thClass)td.classList.add(thClass); if(col.bold) td.classList.add('cell-bold'); if(col.italic)td.classList.add('cell-italic'); if(col.font){var fm={mono:'cell-mono',ui:'cell-ui',display:'cell-display'};var fc=fm[col.font.toLowerCase()];if(fc)td.classList.add(fc);} if(cellType==='progressbar'){ var numVal=parseFloat(strRaw),pMax=col.progressMax||100; var pct=isNaN(numVal)?0:Math.min(100,Math.max(0,(numVal/pMax)*100)); var fillCls=thClass?thClass.replace('cell-','fill-'):'fill-default'; td.classList.add('td-progress'); td.innerHTML='<div class="progress-wrap"><span class="progress-label">'+esc(dispVal)+'</span>'+ '<div class="progress-track"><div class="progress-fill '+fillCls+'" style="width:'+pct.toFixed(1)+'%"></div></div></div>'; } else if(cellType==='badge'){ td.classList.add('td-badge'); var badge=document.createElement('span'); badge.className='cell-badge'+(thClass?' badge-'+thClass.replace('cell-',''):''); badge.textContent=dispVal;td.appendChild(badge); } else if(cellType==='sparkline'){ /* v1.4 F8 — inline mini-chart per row. Cell value must be a numeric array; null/non-array values render as an em dash. Reuses the SPARK helper shared with Add-DhSummary tile sparklines. */ td.classList.add('td-sparkline'); var arr = Array.isArray(rawVal) ? rawVal : null; if (arr && arr.length) { td.innerHTML = SPARK.build(arr, { style: col.sparklineStyle || 'line', width: 100, height: 22 }); } else { td.innerHTML = '<span class="td-sparkline-empty">—</span>'; } } else if(cellType==='chips'||cellType==='path'){ /* v2.3.0 — a list in one cell. chips: tags (FSMO roles); path: an arrow chain (A -> B -> Tier 0). The value is an array, or a string split on ';' / ',' (chips) or '>' / the arrow (path). DOM calls only. */ var items = Array.isArray(rawVal) ? rawVal.map(String) : (strRaw ? strRaw.split(cellType==='chips' ? /\s*[;,]\s*/ : /\s*(?:->|>|→)\s*/) : []); items = items.filter(function(s){return s!=='';}); td.classList.add(cellType==='chips'?'td-chips':'td-path'); if(!items.length){ td.textContent=''; } items.forEach(function(it,i){ if(cellType==='path' && i>0){ var sep=document.createElement('span'); sep.className='cell-path-sep'; sep.textContent='→'; td.appendChild(sep); } var chip=document.createElement('span'); chip.className=cellType==='chips'?'cell-chip':'cell-path-node'; chip.textContent=it; td.appendChild(chip); }); } else if(cellType==='link'){ /* v2.2.0 — link cell. The URL comes from col.urlField (default: this column's value), possibly a property that is not a visible column. Only http(s) and mailto become links; anything else, javascript: included, is shown as plain text. Built with DOM calls, never innerHTML, so neither the URL nor the text can inject markup. */ /* v2.6.0 - also tel:, relative / #fragment URLs (col.relativeLinks or a template), URL templates (col.urlTemplate) and the link target (col.linkTarget), all in dhLinkUrl / dhLinkFill. */ var lk = this._linkFor(col, row, rawVal); if (lk) { var a = document.createElement('a'); a.href = lk.url; a.textContent = col.linkText || dispVal || lk.url; a.className = 'cell-link'; if (lk.newTab) { a.target = '_blank'; a.rel = 'noopener noreferrer'; } a.title = lk.url; td.appendChild(a); } else { var rawUrl = row && col.urlField ? row[col.urlField] : rawVal; td.textContent = dispVal || (rawUrl !== null && rawUrl !== undefined ? String(rawUrl).trim() : ''); } } else { td.textContent=dispVal; } /* Width: a dragged override wins over the author's declared width. Without this the cell falls back to the .data-table td max-width, so a column declared wider than that silently clipped anyway. */ var effW = this._effWidth(col); if(effW){ td.style.maxWidth=effW; td.style.width=effW; } /* Full text for the truncation tooltip. _markTruncated() promotes this to a real title= only on cells that actually overflow, so short cells stay tooltip-free. Kept off badge/progress/sparkline cells, whose visible text is not the raw value. */ if(cellType==='text' && dispVal){ td.dataset.full=dispVal; } /* Pin first column */ if(col.pinFirst){td.classList.add('col-pinned');} return td; }; /* ── Render ─────────────────────────────────────────────────────── */ TableEngine.prototype.render = function(){ this._renderHead();this._renderBody();this._renderFoot();this._renderPaging(); this._renderInfo();this._renderSelClearBtn(); this._renderRemoved(); this._ensureFit(); this._markTruncated(); }; /* ── Column fit (v2.4.0) ──────────────────────────────────────────── Cells are nowrap with an ellipsis and a 340px CSS cap, so a long value lost its tail. Auto fit measures the formatted text of every row (canvas measureText, so it needs no layout and works for rows a virtual table has not drawn) and gives each column the width of its widest text, header included, clamped to [MinWidth, ColumnMaxWidth]. Past the cap the cell still ellipsises and shows its tooltip. Fonts and padding are read from a rendered cell, so a theme or density change re-measures (_refit). */ var _fitCanvas = null; TableEngine.prototype._effWidth = function(col){ return (this.colWidths&&this.colWidths[col.field]) || col.width || (this._fitW&&this._fitW[col.field]) || ''; }; function dhFitFont(cs){ return cs.fontStyle+' '+cs.fontWeight+' '+cs.fontSize+' '+cs.fontFamily; } function dhFitPad(cs){ return (parseFloat(cs.paddingLeft)||0)+(parseFloat(cs.paddingRight)||0); } /* Width in px of one column's widest text, or 0 when it cannot be measured yet. */ TableEngine.prototype._measureCol = function(col, ci, ctx, env){ var cell = env.row && env.row.cells[ci+env.off]; var ccs = cell ? getComputedStyle(cell) : null; var bodyFont = ccs ? dhFitFont(ccs) : '12px monospace'; var padB = ccs ? dhFitPad(ccs) : 24; var label = String(col.label||''); ctx.font = env.headFont; var hw = ctx.measureText(label.toUpperCase()).width + env.ls*label.length + 30 + env.padH; /* + sort icon and grip */ var ct = (col.cellType||'').toLowerCase(); var cw = 0, extra = 0; if (ct === 'sparkline') { cw = 100; } else { var seen = new Set(), list = [], r, s; for (r = 0; r < this.allData.length; r++) { var v = this.allData[r][col.field]; if (v === null || v === undefined || v === '') continue; s = FMT.apply(col, v); if (!s || seen.has(s)) continue; seen.add(s); list.push(s); } if (list.length > 3000) { /* the widest text is among the longest strings */ list.sort(function(a,b){return b.length-a.length;}); list = list.slice(0, 3000); } ctx.font = bodyFont; for (r = 0; r < list.length; r++) { var w = ctx.measureText(list[r]).width; if (w > cw) { cw = w; if (ct === 'chips') extra = 14 * (list[r].split(', ').length); } } if (ct === 'badge') extra = 22; if (ct === 'progressbar') cw = Math.max(cw, 90); if (this.base && ci === 0) extra += 62; /* the New / Changed tag sits in the first cell */ } var w2 = Math.ceil(Math.max(hw, cw + padB + extra) + 4); /* 4px: sub-pixel rounding must never clip the last glyph */ var min = col.minWidth || 48, max = Math.max(min, this.columnMax); return Math.min(max, Math.max(min, w2)); }; /* Fonts and padding come from a rendered cell and header; null until one exists. */ TableEngine.prototype._fitEnv = function(){ var tbody=document.getElementById('tbody-'+this.id), head=document.getElementById('thead-'+this.id); if(!tbody||!head) return null; var row=null, i; for(i=0;i<tbody.rows.length;i++){ var rr=tbody.rows[i]; if(rr.classList.contains('virt-spacer')||rr.querySelector('td.no-data')) continue; row=rr; break; } var th=head.querySelector('th[data-field]'); if(!row||!th) return null; if(!_fitCanvas) _fitCanvas=document.createElement('canvas'); var ctx=_fitCanvas.getContext&&_fitCanvas.getContext('2d'); if(!ctx) return null; var hcs=getComputedStyle(th); return { ctx:ctx, row:row, off:this.multiSelect?1:0, headFont:dhFitFont(hcs), ls:parseFloat(hcs.letterSpacing)||0, padH:dhFitPad(hcs) }; }; TableEngine.prototype._measureFit = function(cols){ var env=this._fitEnv(); if(!env) return null; var out={}, self=this; cols.forEach(function(col,ci){ if(col.width) return; /* the author's width wins */ out[col.field]=self._measureCol(col,ci,env.ctx,env)+'px'; }); return out; }; TableEngine.prototype._ensureFit = function(force){ if(this.columnFit==='fixed'||!this.allData.length) return; var cols=this._visibleCols(), self=this; var complete=this._fitW&&cols.every(function(c){return c.width||(c.field in self._fitW);}); if(complete&&!force) return; var m=this._measureFit(cols); if(!m) return; this._fitW=m; Object.keys(m).forEach(function(f){ if(!self.colWidths[f]) self._applyColWidthDom(f,m[f]); }); }; /* Fonts, density or visible columns changed: measure again. */ TableEngine.prototype._refit = function(){ this._fitW=null; this._ensureFit(true); this._markTruncated(); }; /* Width of one column fitted to its content right now (double-click on its grip). */ TableEngine.prototype._fitOne = function(col){ var ci=this._visibleCols().indexOf(col), env=this._fitEnv(); if(ci<0||!env) return 0; return this._measureCol(col,ci,env.ctx,env); }; /* Give a title= only to cells whose text is actually clipped, so hovering a short cell shows nothing while a clipped one reveals the full value. Measured rather than guessed from string length, because the threshold is the rendered column width - which the author sets, the theme font affects, and the user can now drag. One pass over the rendered rows only: the page when paginated, the virtualised window otherwise. */ TableEngine.prototype._markTruncated = function(){ /* Headers first. A clipped header is worse than a clipped cell - it is the only thing naming the column - so it gets the same measured title=. */ var head=document.getElementById('thead-'+this.id); if(head){ var ths=head.querySelectorAll('th'); for(var h=0;h<ths.length;h++){ var lab=ths[h].querySelector('.col-label'); if(!lab)continue; if(lab.scrollWidth>lab.clientWidth+1){ ths[h].title=lab.textContent; ths[h].classList.add('th-clipped'); } else { ths[h].removeAttribute('title'); ths[h].classList.remove('th-clipped'); } } } var tbody=document.getElementById('tbody-'+this.id); if(!tbody)return; var cells=tbody.querySelectorAll('td[data-full]'); for(var i=0;i<cells.length;i++){ var td=cells[i]; var was=td.dataset.was||''; /* v2.5.2: the old value of a changed cell (baseline) */ if(td.scrollWidth>td.clientWidth+1){ td.title=td.dataset.full+(was?' \u2014 '+was:''); td.classList.add('cell-clipped'); } else { if(was)td.title=was; else td.removeAttribute('title'); td.classList.remove('cell-clipped'); } } }; /* Aggregate footer row — only rendered when any column has an 'aggregate' property */ TableEngine.prototype._renderFoot = function() { var self = this; var visCols = this._visibleCols(); var hasFoot = visCols.some(function(col){ return col.aggregate; }); var tbl = document.getElementById('tbl-' + this.id); if (!tbl) return; /* Remove existing tfoot */ var oldFoot = tbl.querySelector('tfoot'); if (oldFoot) oldFoot.remove(); if (!hasFoot) return; /* Totals follow the selection: with rows ticked (and still in the filtered set) the footer aggregates just those rows; with none ticked it aggregates every filtered row. */ var filtered = this._getFiltered(); var selRows = null; if (this.multiSelect && this.selected.length) { var inView = new Set(filtered); var picked = []; this.selected.forEach(function(i){ var r = self.allData[i]; if (r !== undefined && inView.has(r)) picked.push(r); }); if (picked.length) selRows = picked; } var aggRows = selRows || filtered; var tfoot = document.createElement('tfoot'); var tr = document.createElement('tr'); tr.className = 'tfoot-row' + (selRows ? ' tfoot-selected' : ''); if (this.multiSelect) { var tdBlank = document.createElement('td'); tdBlank.className = 'col-select'; if (selRows) tdBlank.title = 'Totals for the ' + selRows.length + ' selected row' + (selRows.length === 1 ? '' : 's'); tr.appendChild(tdBlank); } visCols.forEach(function(col) { var td = document.createElement('td'); td.className = 'tfoot-cell'; var align = (col.align || 'left').toLowerCase(); if (align === 'right') td.classList.add('cell-right'); if (align === 'center') td.classList.add('cell-center'); if (col.pinFirst) td.classList.add('col-pinned'); var agg = (col.aggregate || '').toLowerCase(); if (!agg) { tr.appendChild(td); return; } var nums = aggRows.map(function(row){ var v = parseFloat(row[col.field]); return isNaN(v) ? null : v; }).filter(function(v){ return v !== null; }); var result = dhAggregate(agg, nums, aggRows.length); if (result !== null) { var displayVal = dhFootText(col, result); td.textContent = displayVal; td.title = (selRows ? 'Selected (' + selRows.length + ') - ' : '') + agg.charAt(0).toUpperCase()+agg.slice(1)+': '+displayVal; } tr.appendChild(td); }); tfoot.appendChild(tr); tbl.appendChild(tfoot); }; TableEngine.prototype._renderHead = function(){ var tr=document.getElementById('thead-'+this.id); if(!tr)return; var self=this;tr.innerHTML=''; var visCols=this._visibleCols(); if(this.multiSelect){ var th=document.createElement('th');th.className='col-select'; th.innerHTML='<input type="checkbox" id="chk-all-'+this.id+'" aria-label="Select all rows">'; th.querySelector('input').addEventListener('change',function(e){ var checked=e.target.checked,filtered=self._getFiltered(); /* Select the rows the user can actually see: the current page when paginated, or the WHOLE filtered set when -Pageable:$false (which renders every row). Slicing by pageSize on a non-pageable table only toggled the first 15 rows — the select-all lied. */ var pageRows=self.pageable ?filtered.slice((self.currentPage-1)*self.pageSize,(self.currentPage-1)*self.pageSize+self.pageSize) :filtered; pageRows.forEach(function(row){ var idx=self._idxOf(row); if(checked){if(self.selected.indexOf(idx)===-1)self.selected.push(idx);} else{self.selected=self.selected.filter(function(i){return i!==idx;});} });self.render(); }); tr.appendChild(th); } visCols.forEach(function(col,ci){ var th=document.createElement('th'); th.dataset.field=col.field; th.setAttribute('scope','col'); /* a11y: column-header association */ var effW=self._effWidth(col); /* max-width as well as width: under table-layout:auto the browser treats width as a suggestion and will not go below the header's own text. */ if(effW){th.style.width=effW;th.style.maxWidth=effW;} if(col.pinFirst)th.classList.add('col-pinned'); var align=(col.align||'left').toLowerCase(); if(align==='right') th.classList.add('th-right'); if(align==='center')th.classList.add('th-center'); var lbl=document.createElement('span');lbl.className='col-label';lbl.textContent=col.label; th.appendChild(lbl); if(col.sortable!==false){ th.classList.add('sortable'); /* a11y: keyboard-operable sort control + screen-reader sort state */ th.tabIndex=0; th.setAttribute('role','columnheader'); var icon=document.createElement('span');icon.className='sort-icon'; /* Show sort position for multi-sort */ var sortPos=self.sortFields.findIndex(function(sf){return sf.field===col.field;}); if(sortPos>-1){ icon.classList.add('sort-'+self.sortFields[sortPos].dir); th.classList.add('sorted'); th.setAttribute('aria-sort',self.sortFields[sortPos].dir==='asc'?'ascending':'descending'); if(self.sortFields.length>1){ var pos=document.createElement('span'); pos.className='sort-pos';pos.textContent=sortPos+1; th.appendChild(pos); } } else { th.setAttribute('aria-sort','none'); } th.appendChild(icon); var doSort=function(shift){ var existIdx=self.sortFields.findIndex(function(sf){return sf.field===col.field;}); if(shift&&self.sortFields.length>0){ /* Multi-sort: add or toggle */ if(existIdx>-1){ if(self.sortFields[existIdx].dir==='asc') self.sortFields[existIdx].dir='desc'; else self.sortFields.splice(existIdx,1); } else { self.sortFields.push({field:col.field,dir:'asc'}); } } else { /* Single sort */ if(existIdx>-1&&self.sortFields.length===1){ self.sortFields[0].dir=self.sortFields[0].dir==='asc'?'desc':'asc'; } else { self.sortFields=[{field:col.field,dir:'asc'}]; } } self.currentPage=1;self.render();self._saveLinkState(); }; th.addEventListener('click',function(e){ doSort(e.shiftKey); }); th.addEventListener('keydown',function(e){ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); doSort(e.shiftKey); } }); } /* Drag-to-resize grip on the column's right edge. Added after the sort handlers so its own pointerdown/click can stop propagation - otherwise every resize would also toggle the sort. */ var grip=document.createElement('span'); grip.className='col-resizer'; grip.setAttribute('role','separator'); grip.setAttribute('aria-orientation','vertical'); grip.setAttribute('aria-label','Resize column '+col.label); grip.tabIndex=0; grip.title='Drag to resize · double-click to fit the text'; grip.addEventListener('click',function(e){e.stopPropagation();}); grip.addEventListener('keydown',function(e){ /* Keyboard parity: arrows nudge, Home resets. */ var step=(e.key==='ArrowLeft')?-16:(e.key==='ArrowRight')?16:0; if(step){ e.preventDefault();e.stopPropagation(); var cur=th.getBoundingClientRect().width; self._setColWidth(col.field,Math.max(40,cur+step)); } else if(e.key==='Home'){ e.preventDefault();e.stopPropagation(); delete self.colWidths[col.field];self.render(); } }); grip.addEventListener('dblclick',function(e){ e.preventDefault();e.stopPropagation(); /* v2.4.0: fit this column to its widest text (it used to reset to the CSS default) */ var fw=self._fitOne(col); if(fw){ self._setColWidth(col.field,fw); self._markTruncated(); } else { delete self.colWidths[col.field];self.render(); } }); grip.addEventListener('pointerdown',function(e){ e.preventDefault();e.stopPropagation(); var startX=e.clientX, startW=th.getBoundingClientRect().width; grip.setPointerCapture(e.pointerId); grip.classList.add('resizing'); var onMove=function(ev){ self._setColWidth(col.field,Math.max(40,startW+(ev.clientX-startX))); }; var onUp=function(ev){ grip.classList.remove('resizing'); try{grip.releasePointerCapture(ev.pointerId);}catch(_){} grip.removeEventListener('pointermove',onMove); grip.removeEventListener('pointerup',onUp); grip.removeEventListener('pointercancel',onUp); /* Re-evaluate which cells are clipped at the new width */ self._markTruncated(); }; grip.addEventListener('pointermove',onMove); grip.addEventListener('pointerup',onUp); grip.addEventListener('pointercancel',onUp); }); th.appendChild(grip); tr.appendChild(th); }); }; /* Apply a dragged column width without a full re-render - rebuilding every row on each pointermove would make the drag crawl on a large table. */ TableEngine.prototype._setColWidth = function(field,px){ var w=Math.round(px)+'px'; this.colWidths[field]=w; this._applyColWidthDom(field,w); }; /* Write a width onto the header and every rendered cell of one column (no re-render). */ TableEngine.prototype._applyColWidthDom = function(field,w){ var idx=this._visibleCols().findIndex(function(c){return c.field===field;}); if(idx<0)return; if(this.multiSelect)idx+=1; var head=document.getElementById('thead-'+this.id); if(head&&head.children[idx]){ head.children[idx].style.width=w; head.children[idx].style.maxWidth=w; } var tbody=document.getElementById('tbody-'+this.id); if(!tbody)return; var rows=tbody.rows; for(var r=0;r<rows.length;r++){ var cell=rows[r].cells[idx]; if(cell){cell.style.width=w;cell.style.maxWidth=w;} } }; /* Build one <tr> for a data row. Extracted from _renderBody so both the paginated path and the virtualised (non-pageable) path produce identical rows. globalIdx is the row's index in allData (used for selection/links). */ TableEngine.prototype._buildRow = function(row, globalIdx){ var self=this; var visCols=this._visibleCols(); var tr=document.createElement('tr'); tr.dataset.idx=globalIdx; /* Row highlight */ this.columns.forEach(function(col){ if(col.rowHighlight){ var cls=getThresholdClass(col,row[col.field]); if(cls)tr.classList.add('row-hl-'+cls.replace('cell-','')); } }); if(this.selected.indexOf(globalIdx)!==-1)tr.classList.add('row-selected'); if(this.multiSelect)tr.setAttribute('aria-selected',tr.classList.contains('row-selected')?'true':'false'); tr.addEventListener('click',function(e){ if(e.target.type==='checkbox')return; if(e.target.closest&&e.target.closest('a'))return; /* a link cell opens its link, not the drawer */ self._handleRowClick(globalIdx,row,tr); }); if(this.detail){ /* v2.3.0 - keyboard access to the drawer */ tr.classList.add('dh-drawer-row');tr.tabIndex=0; tr.addEventListener('keydown',function(e){ if(e.target!==tr)return; if(e.key==='Enter'||e.key===' '){e.preventDefault();self._handleRowClick(globalIdx,row,tr);} }); } if(this.multiSelect){ var tdChk=document.createElement('td');tdChk.className='col-select'; var chk=document.createElement('input');chk.type='checkbox';chk.setAttribute('aria-label','Select row '+(globalIdx+1)); chk.checked=this.selected.indexOf(globalIdx)!==-1; chk.addEventListener('change',function(){ if(chk.checked){if(self.selected.indexOf(globalIdx)===-1)self.selected.push(globalIdx);} else{self.selected=self.selected.filter(function(i){return i!==globalIdx;});} self._syncRowClass(tr,globalIdx);self._renderInfo();self._renderSelClearBtn();self._renderFoot(); }); tdChk.appendChild(chk);tr.appendChild(tdChk); } visCols.forEach(function(col){tr.appendChild(self._renderCell(col,row[col.field],row));}); var bs=this.base&&this._baseState.get(row); if(bs){ tr.classList.add(bs.s==='n'?'row-new':'row-changed'); var off=this.multiSelect?1:0, first=tr.cells[off]; if(first){ var tag=document.createElement('span'); tag.className='chg-tag '+(bs.s==='n'?'chg-new':'chg-changed'); tag.textContent=bs.s==='n'?'New':'Changed'; first.insertBefore(tag,first.firstChild); } if(bs.s==='c'){ visCols.forEach(function(col,i){ if(bs.f.indexOf(col.field)===-1)return; var td=tr.cells[off+i]; if(!td)return; td.classList.add('cell-changed'); td.dataset.was=dhWasText(col,bs.w[col.field],FMT.apply); td.title=(td.title?td.title+' \u2014 ':'')+td.dataset.was; }); } } return tr; }; /* v1.7.0 — row virtualisation threshold. Only the non-pageable path (which now shows ALL rows) windows the DOM; below this count it renders plainly. */ var VIRTUALISE_ABOVE = 150; TableEngine.prototype._renderBody = function(){ var tbody=document.getElementById('tbody-'+this.id); if(!tbody)return; var filtered=this._getFiltered(); var self=this;var visCols=this._visibleCols(); var colSpan=visCols.length+(this.multiSelect?1:0); /* Update nav badge with current filtered count */ var navBadge=document.querySelector('.nav-badge[data-table="'+this.id+'"]'); if(navBadge) navBadge.textContent=filtered.length; updateNavBadge(this.id); if(filtered.length===0){ this._teardownVirtual(); tbody.innerHTML=''; var tr=document.createElement('tr');var td=document.createElement('td'); td.colSpan=colSpan;td.className='no-data'; td.textContent='No matching records found.';tr.appendChild(td);tbody.appendChild(tr);return; } /* v2.2.0 — print path: the first _printRows filtered rows, plainly, no pages and no virtual window (a printout cannot scroll). Set and cleared by the beforeprint / afterprint handlers in the print module. */ if(this._printRows){ this._teardownVirtual(); tbody.innerHTML=''; filtered.slice(0,this._printRows).forEach(function(row){ tbody.appendChild(self._buildRow(row, self._idxOf(row))); }); return; } /* Paginated path (default) — unchanged: render only the current page. */ if(this.pageable){ this._teardownVirtual(); tbody.innerHTML=''; var start=(this.currentPage-1)*this.pageSize; filtered.slice(start,start+this.pageSize).forEach(function(row){ tbody.appendChild(self._buildRow(row, self._idxOf(row))); }); return; } /* Non-pageable path — render ALL rows. Small tables render plainly; large ones are virtualised so the DOM only holds the visible window. (Before v1.7.0 this path incorrectly showed only pageSize rows.) */ if(filtered.length<=VIRTUALISE_ABOVE){ this._teardownVirtual(); tbody.innerHTML=''; filtered.forEach(function(row){ tbody.appendChild(self._buildRow(row, self._idxOf(row))); }); return; } this._renderVirtual(filtered, colSpan); }; /* Virtualised body for a large non-pageable table. Renders only the rows near the scroll viewport, padded by top/bottom spacer <tr>s sized to preserve the scrollbar geometry. Row height is measured once from a real row. */ TableEngine.prototype._renderVirtual = function(filtered, colSpan){ var self=this; var tbody=document.getElementById('tbody-'+this.id); var wrapper=document.getElementById('tbl-'+this.id).closest('.table-wrapper'); if(!wrapper){ /* no scroll container — fall back to rendering all */ tbody.innerHTML=''; filtered.forEach(function(row){ tbody.appendChild(self._buildRow(row, self._idxOf(row))); }); return; } wrapper.classList.add('table-virtual'); /* Measure a representative row height once. */ tbody.innerHTML=''; var probe=this._buildRow(filtered[0], this._idxOf(filtered[0])); tbody.appendChild(probe); var rowH=probe.getBoundingClientRect().height||34; function spacer(){ var tr=document.createElement('tr');tr.className='virt-spacer'; var td=document.createElement('td');td.colSpan=colSpan; tr.appendChild(td);return tr; } var topSpacer=spacer(), botSpacer=spacer(); function renderWindow(){ var scrollTop=wrapper.scrollTop; var vpH=wrapper.clientHeight||400; var buffer=8; var first=Math.max(0, Math.floor(scrollTop/rowH)-buffer); var count=Math.ceil(vpH/rowH)+buffer*2; var last=Math.min(filtered.length, first+count); topSpacer.firstChild.style.height=(first*rowH)+'px'; botSpacer.firstChild.style.height=((filtered.length-last)*rowH)+'px'; tbody.innerHTML=''; tbody.appendChild(topSpacer); for(var i=first;i<last;i++){ tbody.appendChild(self._buildRow(filtered[i], self._idxOf(filtered[i]))); } tbody.appendChild(botSpacer); } var ticking=false; this._teardownVirtual(); /* clear any previous handler before wiring a new one */ this._virtScrollHandler=function(){ if(ticking)return; ticking=true; requestAnimationFrame(function(){ renderWindow(); ticking=false; }); }; this._virtWrapper=wrapper; wrapper.addEventListener('scroll', this._virtScrollHandler); renderWindow(); }; TableEngine.prototype._teardownVirtual = function(){ if(this._virtWrapper && this._virtScrollHandler){ this._virtWrapper.removeEventListener('scroll', this._virtScrollHandler); this._virtWrapper.classList.remove('table-virtual'); } this._virtWrapper=null; this._virtScrollHandler=null; }; TableEngine.prototype._handleRowClick=function(globalIdx,row,tr){ if(this.multiSelect){ var pos=this.selected.indexOf(globalIdx); if(pos===-1)this.selected.push(globalIdx);else this.selected.splice(pos,1); this._syncRowClass(tr,globalIdx);this._renderInfo();this._renderSelClearBtn();this._renderFoot(); this._notifyLinks(this.selected.length>0?this.allData[this.selected[0]]:null); } else { if(this.selected.length===1&&this.selected[0]===globalIdx){this.selected=[];this._notifyLinks(null);} else{this.selected=[globalIdx];this._notifyLinks(row);} this.render(); } if(this.detail)this._openDetail(globalIdx,row,tr); }; /* v2.3.0 - the row's properties as label/value tiles in the shared drawer. The row <tr> is re-created by render(), so focus goes back to it by selector. */ TableEngine.prototype._openDetail=function(globalIdx,row,tr){ var d=this.detail; if(!d||typeof dhDrawerOpen!=='function')return; function fmt(v){ if(v===null||v===undefined)return ''; if(Array.isArray(v))return v.join(', '); if(typeof v==='object'){try{return JSON.stringify(v);}catch(ex){return String(v);}} return String(v); } var title=d.titleField?fmt(row[d.titleField]):''; /* v2.6.0 - a link column's value is the same link in the panel */ var self=this, linkCol={}; this.columns.forEach(function(c){ if(c.cellType==='link')linkCol[c.field]=c; }); dhDrawerOpen({ title:title||this.title, subtitle:title?this.title:'', sections:[{kind:'kv',columns:2,items:d.fields.map(function(f){ var it={label:f.label,value:fmt(row[f.field])}, c=linkCol[f.field]; var lk=c?self._linkFor(c,row,row[f.field]):null; if(lk){ it._link=lk.url; it._newTab=lk.newTab; if(c.linkText)it.value=c.linkText; else if(!it.value)it.value=lk.url; } return it; })}] },{opener:tr,restore:'#tbody-'+this.id+' tr[data-idx="'+globalIdx+'"]'}); }; /* v2.6.0 - the link of a CellType='link' cell: { url, newTab }, or null when the value must stay text. A template is author-written, so a relative template is allowed; a data value is relative only with RelativeLinks = $true (a link column holding "N/A" must not become a link to ./N/A). */ TableEngine.prototype._linkFor=function(col,row,rawVal){ var url; if(col.urlTemplate){ url=dhLinkUrl(dhLinkFill(col.urlTemplate,row),true); } else { url=dhLinkUrl(row&&col.urlField?row[col.urlField]:rawVal,!!col.relativeLinks); } return url?{url:url,newTab:dhLinkNewTab(url,col.linkTarget||'')}:null; }; TableEngine.prototype._notifyLinks=function(masterRow){ var self=this; this.outLinks.forEach(function(link){ var det=engines[link.detailTableId];if(!det)return; det.applyLinkedFilter(link.detailField,masterRow!==null?masterRow[link.masterField]:null,self.title); }); }; /* After linked filter is applied, refresh bar charts referencing the detail table */ TableEngine.prototype.applyLinkedFilter = (function(_orig){ return function(field, value, masterTitle) { _orig.call(this, field, value, masterTitle); this._refreshLinkedBarCharts(); }; })(TableEngine.prototype.applyLinkedFilter); TableEngine.prototype._syncRowClass=function(tr,globalIdx){ var on=this.selected.indexOf(globalIdx)!==-1; tr.classList.toggle('row-selected',on); if(this.multiSelect)tr.setAttribute('aria-selected',on?'true':'false'); /* grid rows announce their selection */ }; /* ── Pagination ─────────────────────────────────────────────────── */ TableEngine.prototype._renderPaging=function(){ var container=document.getElementById('paging-'+this.id); if(!container||!this.pageable)return; var filtered=this._getFiltered(); var totalPages=Math.max(1,Math.ceil(filtered.length/this.pageSize)); var self=this;container.innerHTML='';if(totalPages<=1)return; function mkBtn(label,page,disabled,active){ var b=document.createElement('button'); b.className='page-btn'+(active?' page-active':''); b.textContent=label;b.disabled=disabled; b.addEventListener('click',function(){if(!disabled){self.currentPage=page;self.render();self._saveLinkState();}}); return b; } container.appendChild(mkBtn('\u00AB',1,self.currentPage===1,false)); container.appendChild(mkBtn('\u2039',self.currentPage-1,self.currentPage===1,false)); var start=Math.max(1,self.currentPage-2),end=Math.min(totalPages,start+4); start=Math.max(1,end-4); if(start>1){container.appendChild(mkBtn('1',1,false,false));if(start>2){var e1=document.createElement('span');e1.className='page-ellipsis';e1.textContent='\u2026';container.appendChild(e1);}} for(var p=start;p<=end;p++)container.appendChild(mkBtn(String(p),p,false,p===self.currentPage)); if(end<totalPages){if(end<totalPages-1){var e2=document.createElement('span');e2.className='page-ellipsis';e2.textContent='\u2026';container.appendChild(e2);}container.appendChild(mkBtn(String(totalPages),totalPages,false,false));} container.appendChild(mkBtn('\u203A',self.currentPage+1,self.currentPage===totalPages,false)); container.appendChild(mkBtn('\u00BB',totalPages,self.currentPage===totalPages,false)); }; TableEngine.prototype._renderInfo=function(){ var el=document.getElementById('info-'+this.id);if(!el)return; var filtered=this._getFiltered(); var total=this.allData.length,shown=filtered.length; var start=(this.currentPage-1)*this.pageSize+1; var end=Math.min(start+this.pageSize-1,shown); var text=shown<total?(start+'\u2013'+end+' of '+shown+' (filtered from '+total+')'):(start+'\u2013'+end+' of '+total); if(this.selected.length>0)text+=' \u2014 '+this.selected.length+' selected'; el.textContent=text; }; TableEngine.prototype._renderSelClearBtn=function(){ var btn=document.getElementById('clear-sel-'+this.id); if(btn)btn.style.display=this.selected.length>0?'inline-flex':'none'; }; /* ── Per-table Pie Charts (Add-DhTable -Charts) ───────────────────── Delegates SVG generation to the shared chartPalette() / buildPieSvg() helpers so the standalone Add-DhPieChart and the per-table chart stay visually consistent. */ TableEngine.prototype._chartPalette = function () { return chartPalette(); }; TableEngine.prototype._renderCharts = function () { if (!this.charts || !this.charts.length) return; var container = document.getElementById('charts-' + this.id); if (!container) return; container.innerHTML = ''; var self = this; /* v2.1.4 - 'pie' draws solid, 'donut' (the default) draws a donut. v2.2.0 - 'bar' draws through the shared aggregateBars / buildBarHtml. Add-DhTable validates Type and drops anything else with a warning. */ this.charts.forEach(function (chart) { var t = (chart.type || 'donut').toLowerCase(); if (t === 'pie' || t === 'donut') { container.appendChild(self._buildPieChart(chart, t)); } else if (t === 'bar') { container.appendChild(self._buildBarChart(chart)); } }); }; /* v2.2.0 — a bar chart inside the table's chart strip. Same aggregation as Add-DhBarChart, over this table's filtered rows. */ TableEngine.prototype._buildBarChart = function (chart) { var opts = { valueField: chart.valueField, aggregate: chart.aggregate, topN: chart.topN || 10, unit: chart.unit, decimals: chart.decimals, showCount: true, showPercent: false, clickable: false }; var agg = aggregateBars(this._getFiltered(), chart.field, opts); opts.total = agg.total; var panel = document.createElement('div'); panel.className = 'chart-panel chart-panel-bar'; var title = chart.title || chart.field; panel.innerHTML = (title ? '<div class="chart-title">'+esc(title)+'</div>' : '')+ (agg.entries.length ? '<div class="bar-chart">'+buildBarHtml(agg.entries, opts)+'</div>' : '<div class="chart-body chart-empty">No data</div>'); return panel; }; TableEngine.prototype._buildPieChart = function (chart, style) { var palette = chartPalette(); var counts = {}; this._getFiltered().forEach(function (row) { var v = (row[chart.field] != null) ? String(row[chart.field]) : '(empty)'; counts[v] = (counts[v] || 0) + 1; }); var entries = Object.keys(counts).map(function (k, i) { return { label: k, count: counts[k], color: palette[i % palette.length] }; }).sort(function (a, b) { return b.count - a.count; }); return buildPieSvg(entries, { style: style === 'pie' ? 'pie' : 'donut', title: chart.title || chart.field, showTotal: true, showLegend: true }); }; /* ── Export ─────────────────────────────────────────────────────── */ TableEngine.prototype._triggerDownload=function(url,filename){ var a=document.createElement('a');a.href=url;a.download=filename; document.body.appendChild(a);a.click();document.body.removeChild(a); if(url.startsWith('blob:'))setTimeout(function(){URL.revokeObjectURL(url);},2000); }; TableEngine.prototype._exportRows=function(){ var cols=this._visibleCols(),rows=this._getFiltered(); if(!this.base)return{cols:cols,rows:rows}; /* baseline: a Change column; the removed rows only when the reader ticked the box */ var self=this; cols=cols.concat([{field:'__change',label:'Change'}]); rows=rows.map(function(row){var s=self._baseState.get(row);return Object.assign({},row,{__change:s?(s.s==='n'?'New':'Changed'):''});}); if(this._includeRemoved)this.base.removedRows.forEach(function(r){rows.push(Object.assign({},r,{__change:'Removed'}));}); return{cols:cols,rows:rows}; }; TableEngine.prototype.exportCsv=function(){ var r=this._exportRows(),BOM='\uFEFF'; var lines=[r.cols.map(function(c){return'"'+c.label.replace(/"/g,'""')+'"';}).join(',')]; r.rows.forEach(function(row){lines.push(r.cols.map(function(c){var v=dhCsvSafe(FMT.forExport(c,row[c.field]));return'"'+v.replace(/"/g,'""')+'"';}).join(','));}); this._triggerDownload(URL.createObjectURL(new Blob([BOM+lines.join('\r\n')],{type:'text/csv;charset=utf-8;'})),this.exportFileName+'.csv'); }; /* v2.2.0 — the library is loaded on demand (see loadExportLib); a failure disables the button with a tooltip instead of raising an alert(). */ TableEngine.prototype.exportExcel=function(){ var self=this; setExportBusy('xlsx',true); loadExportLib('xlsx').then(function(){ setExportBusy('xlsx',false); self._writeExcel(); }, function(){ setExportBusy('xlsx',false); markExportUnavailable('xlsx'); }); }; TableEngine.prototype._writeExcel=function(){ var r=this._exportRows(); var aoa=[r.cols.map(function(c){return c.label;})]; r.rows.forEach(function(row){aoa.push(r.cols.map(function(c){ var raw=row[c.field],fmt=(c.format||'').toLowerCase(); if(fmt==='number'||fmt==='currency'||fmt==='percent'){var n=parseFloat(raw);return isNaN(n)?FMT.apply(c,raw):n;} return FMT.forExport(c,raw); }));}); var ws=XLSX.utils.aoa_to_sheet(aoa); ws['!cols']=r.cols.map(function(c){var max=c.label.length;r.rows.forEach(function(row){var v=String(FMT.apply(c,row[c.field])||'');if(v.length>max)max=v.length;});return{wch:Math.min(max+2,60)};}); ws['!freeze']={xSplit:0,ySplit:1}; /* Excel sheet names: max 31 chars, may not contain : \ / ? * [ ] and may not be blank — an invalid name makes book_append_sheet throw and the whole export silently fails. Sanitise, then fall back to 'Sheet1'. */ var sheetName=String(this.title||'').replace(/[:\\\/?*\[\]]/g,' ').replace(/\s+/g,' ').trim().substring(0,31); if(!sheetName)sheetName='Sheet1'; var wb=XLSX.utils.book_new();XLSX.utils.book_append_sheet(wb,ws,sheetName); XLSX.writeFile(wb,this.exportFileName+'.xlsx'); }; TableEngine.prototype.exportPdf=function(){ var self=this; setExportBusy('pdf',true); loadPdfLibs().then(function(){ setExportBusy('pdf',false); self._writePdf(); }, function(){ setExportBusy('pdf',false); markExportUnavailable('pdf'); }); }; TableEngine.prototype._writePdf=function(){ var JsPDF=window.jsPDF||(window.jspdf&&window.jspdf.jsPDF); var r=this._exportRows(); var orient=(r.cols.length>6||r.rows.length>30)?'landscape':'portrait'; var doc=new JsPDF({orientation:orient,unit:'mm',format:'a4'}); doc.setFontSize(15);doc.setTextColor(0,119,204);doc.text(this.title,14,16); doc.setFontSize(8);doc.setTextColor(100,110,130);doc.text('Rows: '+r.rows.length+' | '+new Date().toLocaleString(),14,22); var colStyles={}; r.cols.forEach(function(col,i){var align=(col.align||'left').toLowerCase();if(align==='right'||align==='center')colStyles[i]={halign:align};}); doc.autoTable({head:[r.cols.map(function(c){return c.label;})], body:r.rows.map(function(row){return r.cols.map(function(c){var v=FMT.apply(c,row[c.field]);return(v!==null&&v!==undefined)?String(v):'';});}), startY:27,columnStyles:colStyles, styles:{fontSize:8,cellPadding:2,overflow:'ellipsize',font:'helvetica'}, headStyles:{fillColor:[0,77,153],textColor:255,fontStyle:'bold',halign:'left'}, alternateRowStyles:{fillColor:[240,245,252]},margin:{left:14,right:14}}); var pageCount=doc.internal.getNumberOfPages(); for(var i=1;i<=pageCount;i++){doc.setPage(i);doc.setFontSize(7);doc.setTextColor(160,160,180);doc.text('Page '+i+' of '+pageCount,doc.internal.pageSize.getWidth()-14,doc.internal.pageSize.getHeight()-6,{align:'right'});} doc.save(this.exportFileName+'.pdf'); }; '@ } |