← back to Commercialrealestate
Add spreadsheet-style draggable column resizing (col-resize.js) to all table pages
dd4abf67fe651e0f03ff6d7bdf940742720c4a79 · 2026-07-12 11:01:16 -0700 · Steve
Files touched
M public/broker-grid.htmlA public/col-resize.jsM public/condos.htmlM public/crcp.htmlM public/deals.htmlM public/fha-loans.htmlM public/gov-agents.htmlM public/graphics.htmlM public/index.htmlM public/licensed-agents.htmlM public/mls.htmlM public/rent-rolls.htmlM public/sales.html
Diff
commit dd4abf67fe651e0f03ff6d7bdf940742720c4a79
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 12 11:01:16 2026 -0700
Add spreadsheet-style draggable column resizing (col-resize.js) to all table pages
---
public/broker-grid.html | 1 +
public/col-resize.js | 219 ++++++++++++++++++++++++++++++++++++++++++++
public/condos.html | 1 +
public/crcp.html | 1 +
public/deals.html | 1 +
public/fha-loans.html | 1 +
public/gov-agents.html | 1 +
public/graphics.html | 1 +
public/index.html | 1 +
public/licensed-agents.html | 1 +
public/mls.html | 1 +
public/rent-rolls.html | 1 +
public/sales.html | 1 +
13 files changed, 231 insertions(+)
diff --git a/public/broker-grid.html b/public/broker-grid.html
index b62078d..50b424d 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -322,5 +322,6 @@ if(railEl){ railEl.addEventListener('click',e=>{ const h=e.target.closest('h4');
fetch('/api/brokers/all').then(r=>r.json()).then(d=>{DATA=d.brokers||[];autoCols();if(window.__buildSortSel)window.__buildSortSel();buildRail();render();})
.catch(()=>{$('#count').textContent='failed to load /api/brokers/all';});
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/col-resize.js b/public/col-resize.js
new file mode 100644
index 0000000..4cd3e22
--- /dev/null
+++ b/public/col-resize.js
@@ -0,0 +1,219 @@
+/*!
+ * col-resize.js — spreadsheet-style draggable column resizing for any <table>.
+ * Zero dependencies. Drop-in: <script src="/col-resize.js" defer></script>
+ *
+ * Behaviour:
+ * • Hover a column's right edge → col-resize cursor → drag to widen/narrow.
+ * • Only the dragged column changes; the rest hold still (table-layout:fixed).
+ * • Double-click a handle → auto-fit that column to its content.
+ * • Widths persist in localStorage, keyed by page + table signature + column,
+ * so they survive reloads AND JS re-renders of the same table.
+ * • Auto-enhances tables added later (fetch-rendered grids) via MutationObserver.
+ *
+ * Opt out per table with <table data-no-resize>. Re-scan manually: ColResize.refresh().
+ */
+(function () {
+ 'use strict';
+ if (window.__colResizeLoaded) return;
+ window.__colResizeLoaded = true;
+
+ var MIN = 40; // px, smallest a column may shrink to
+ var LS = 'cr:'; // localStorage key prefix
+ var HANDLE_W = 8; // px hit-area of the drag handle
+
+ // ---- persistence ----------------------------------------------------------
+ function tableSig(headers) {
+ // Stable id from header text + count — survives re-render & column reorder.
+ var s = headers.map(function (h) { return (h.textContent || '').trim(); }).join('|') + '#' + headers.length;
+ var hash = 0;
+ for (var i = 0; i < s.length; i++) { hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0; }
+ return 't' + (hash >>> 0).toString(36);
+ }
+ function keyFor(sig, idx) { return LS + location.pathname + ':' + sig + ':' + idx; }
+ function save(sig, idx, w) { try { localStorage.setItem(keyFor(sig, idx), String(Math.round(w))); } catch (e) {} }
+ function load(sig, idx) { try { var v = localStorage.getItem(keyFor(sig, idx)); return v ? parseFloat(v) : null; } catch (e) { return null; } }
+ function clear(sig, idx) { try { localStorage.removeItem(keyFor(sig, idx)); } catch (e) {} }
+
+ // ---- header-cell detection ------------------------------------------------
+ function headerCells(table) {
+ var thead = table.tHead;
+ if (thead && thead.rows.length) {
+ var row = thead.rows[thead.rows.length - 1];
+ if (row && row.cells.length) return Array.prototype.slice.call(row.cells);
+ }
+ // No <thead> — treat the first row's cells as headers.
+ var first = table.rows[0];
+ return first ? Array.prototype.slice.call(first.cells) : [];
+ }
+
+ // ---- colgroup: one <col> per column ---------------------------------------
+ function ensureColgroup(table, n) {
+ var cg = table.querySelector('colgroup[data-cr]');
+ if (cg && cg.children.length === n) return cg;
+ if (cg) cg.remove();
+ cg = document.createElement('colgroup');
+ cg.setAttribute('data-cr', '1');
+ for (var i = 0; i < n; i++) cg.appendChild(document.createElement('col'));
+ table.insertBefore(cg, table.firstChild);
+ return cg;
+ }
+
+ // ---- wrap table in a horizontal-scroll container --------------------------
+ function ensureScroll(table) {
+ var p = table.parentElement;
+ if (p && p.classList.contains('cr-scroll')) return p;
+ var wrap = document.createElement('div');
+ wrap.className = 'cr-scroll';
+ p.insertBefore(wrap, table);
+ wrap.appendChild(table);
+ return wrap;
+ }
+
+ // ---- enhance one table ----------------------------------------------------
+ function enhance(table) {
+ if (table.dataset.crDone === '1' || table.hasAttribute('data-no-resize')) return;
+ var headers = headerCells(table);
+ if (headers.length < 2) return; // nothing meaningful to resize
+ table.dataset.crDone = '1';
+
+ var sig = tableSig(headers);
+ var cols = ensureColgroup(table, headers.length).children;
+ ensureScroll(table);
+
+ // Snapshot current widths → explicit px, THEN switch to fixed layout so the
+ // browser stops redistributing. Saved widths override the snapshot.
+ var measured = headers.map(function (h) { return Math.max(MIN, Math.round(h.getBoundingClientRect().width)); });
+ table.style.tableLayout = 'fixed';
+ table.style.width = 'auto';
+ for (var i = 0; i < headers.length; i++) {
+ var saved = load(sig, i);
+ cols[i].style.width = (saved != null ? Math.max(MIN, saved) : measured[i]) + 'px';
+ }
+
+ // Add a drag handle to each header cell.
+ headers.forEach(function (th, idx) {
+ var cs = getComputedStyle(th);
+ if (cs.position === 'static') th.style.position = 'relative';
+ var handle = document.createElement('span');
+ handle.className = 'cr-handle';
+ handle.setAttribute('role', 'separator');
+ handle.setAttribute('aria-orientation', 'vertical');
+ handle.title = 'Drag to resize • double-click to auto-fit';
+ th.appendChild(handle);
+ wireHandle(handle, table, cols, headers, sig, idx);
+ });
+ }
+
+ function wireHandle(handle, table, cols, headers, sig, idx) {
+ var startX = 0, startW = 0, dragging = false;
+
+ function down(e) {
+ dragging = true;
+ startX = (e.touches ? e.touches[0].clientX : e.clientX);
+ startW = cols[idx].getBoundingClientRect().width;
+ document.body.classList.add('cr-dragging');
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ window.addEventListener('touchmove', move, { passive: false });
+ window.addEventListener('touchend', up);
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ function move(e) {
+ if (!dragging) return;
+ var x = (e.touches ? e.touches[0].clientX : e.clientX);
+ var w = Math.max(MIN, startW + (x - startX));
+ cols[idx].style.width = w + 'px';
+ if (e.cancelable) e.preventDefault();
+ }
+ function up() {
+ if (!dragging) return;
+ dragging = false;
+ document.body.classList.remove('cr-dragging');
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ window.removeEventListener('touchmove', move);
+ window.removeEventListener('touchend', up);
+ save(sig, idx, cols[idx].getBoundingClientRect().width);
+ }
+ // Double-click → auto-fit this column to its content.
+ function autofit(e) {
+ e.preventDefault(); e.stopPropagation();
+ clear(sig, idx);
+ // Remember every column's current width, let this one go auto, measure, restore.
+ var widths = [];
+ for (var i = 0; i < cols.length; i++) widths[i] = cols[i].style.width;
+ table.style.tableLayout = 'auto';
+ cols[idx].style.width = '';
+ var natural = Math.max(MIN, Math.round(headers[idx].getBoundingClientRect().width));
+ for (var j = 0; j < cols.length; j++) cols[j].style.width = widths[j];
+ cols[idx].style.width = natural + 'px';
+ table.style.tableLayout = 'fixed';
+ save(sig, idx, natural);
+ }
+
+ handle.addEventListener('pointerdown', down);
+ handle.addEventListener('dblclick', autofit);
+ // Fallback for browsers without Pointer Events.
+ if (!window.PointerEvent) handle.addEventListener('touchstart', down, { passive: false });
+ }
+
+ // ---- inject the small stylesheet once -------------------------------------
+ function injectCSS() {
+ if (document.getElementById('cr-style')) return;
+ var css = ''
+ + '.cr-scroll{overflow-x:auto;max-width:100%;}'
+ + '.cr-handle{position:absolute;top:0;right:-' + (HANDLE_W / 2) + 'px;width:' + HANDLE_W + 'px;height:100%;'
+ + 'cursor:col-resize;user-select:none;touch-action:none;z-index:5;}'
+ + '.cr-handle:hover::after,.cr-dragging .cr-handle::after{content:"";position:absolute;top:0;left:50%;'
+ + 'width:2px;height:100%;transform:translateX(-50%);background:currentColor;opacity:.55;}'
+ + 'body.cr-dragging{cursor:col-resize!important;user-select:none!important;}'
+ + 'body.cr-dragging *{cursor:col-resize!important;}';
+ var s = document.createElement('style');
+ s.id = 'cr-style';
+ s.textContent = css;
+ document.head.appendChild(s);
+ }
+
+ // ---- scan + observe -------------------------------------------------------
+ function scan(root) {
+ var tables = (root || document).querySelectorAll('table');
+ for (var i = 0; i < tables.length; i++) {
+ try { enhance(tables[i]); } catch (e) { /* never let one bad table break the page */ }
+ }
+ }
+
+ var pending = false;
+ function scheduleScan() {
+ if (pending) return;
+ pending = true;
+ (window.requestAnimationFrame || setTimeout)(function () { pending = false; scan(document); });
+ }
+
+ function start() {
+ injectCSS();
+ scan(document);
+ if (window.MutationObserver) {
+ new MutationObserver(function (muts) {
+ for (var i = 0; i < muts.length; i++) {
+ if (muts[i].addedNodes && muts[i].addedNodes.length) { scheduleScan(); break; }
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+ }
+ }
+
+ window.ColResize = {
+ refresh: function () { scan(document); },
+ reset: function () { // wipe all saved widths for this page
+ try {
+ Object.keys(localStorage).forEach(function (k) {
+ if (k.indexOf(LS + location.pathname + ':') === 0) localStorage.removeItem(k);
+ });
+ } catch (e) {}
+ location.reload();
+ }
+ };
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
+ else start();
+})();
diff --git a/public/condos.html b/public/condos.html
index 7412a73..256caf6 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -328,5 +328,6 @@ if(railEl){ railEl.addEventListener('click',e=>{ const h=e.target.closest('h4');
document.querySelectorAll('.rail .rsec').forEach(s=>s.classList.add('collapsed')); }
boot();
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/crcp.html b/public/crcp.html
index 2a9dfed..d5bf9ad 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -302,5 +302,6 @@ async function poll(){
poll(); setInterval(poll, 3000);
loadSegments(); setInterval(loadSegments, 20000);
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/deals.html b/public/deals.html
index f1a839e..756a2dc 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -288,5 +288,6 @@ document.getElementById('metric').addEventListener('click', e=>{
});
pull(); // initial load
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/fha-loans.html b/public/fha-loans.html
index e87cf24..de6e40f 100644
--- a/public/fha-loans.html
+++ b/public/fha-loans.html
@@ -247,5 +247,6 @@ async function load(){
}
load();
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/gov-agents.html b/public/gov-agents.html
index a91c178..389b276 100644
--- a/public/gov-agents.html
+++ b/public/gov-agents.html
@@ -89,5 +89,6 @@ $('#thead').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)
$('#density').addEventListener('change',()=>{limit=+$('#density').value;loadMetro();});
loadIndex();
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/graphics.html b/public/graphics.html
index a26aaf6..eb3d6a2 100644
--- a/public/graphics.html
+++ b/public/graphics.html
@@ -183,5 +183,6 @@ async function j(u){ const r=await fetch(u); return r.json(); }
`<span style="color:var(--mut)">source: HUD FHA list · cre broker graph</span>`;
})().catch(e=>{ $('#stats').innerHTML='<span style="color:var(--red)">load error: '+e.message+'</span>'; });
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/index.html b/public/index.html
index ffcbc97..fb11528 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1484,5 +1484,6 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
recompute(); syncChips(); render();
}).catch(e=>{ $('#sub').textContent='Failed to load data/ranked.json — run: npm run analyze'; });
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/licensed-agents.html b/public/licensed-agents.html
index 9df5234..d088173 100644
--- a/public/licensed-agents.html
+++ b/public/licensed-agents.html
@@ -210,5 +210,6 @@ $('#density').oninput=e=>{document.documentElement.style.setProperty('--cw',e.ta
const dv=localStorage.getItem('laDens')||'320'; $('#density').value=dv; document.documentElement.style.setProperty('--cw',dv+'px');})();
if(localStorage.getItem('laMode')==='brokers') setMode('brokers'); else load();
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/mls.html b/public/mls.html
index 8b62ed3..48e6552 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -428,5 +428,6 @@ fetch('/data/ranked.json').then(r=>r.json()).then(async d=>{ DATA=(d.ranked||d)|
autoCols(); buildRail(); render(); })
.catch(()=>{ $('#count').textContent='failed to load /data/ranked.json'; });
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/rent-rolls.html b/public/rent-rolls.html
index b2652c7..41dad95 100644
--- a/public/rent-rolls.html
+++ b/public/rent-rolls.html
@@ -134,5 +134,6 @@ fetch('/api/rent-rolls').then(r=>r.json()).then(d=>{
});
}).catch(e=>{ document.getElementById('rolls').innerHTML='<div class="glass empty">Failed to load rent rolls: '+e.message+'</div>'; });
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
diff --git a/public/sales.html b/public/sales.html
index d7b9230..97ca9fe 100644
--- a/public/sales.html
+++ b/public/sales.html
@@ -95,5 +95,6 @@ async function load(){
}
load();
</script>
+ <script src="/col-resize.js" defer></script>
</body>
</html>
← 367513f CRCP multi-metro listings sweep: generalize SFV Crexi scrape
·
back to Commercialrealestate
·
col-resize: add click-to-sort headers (asc/desc/reset, numer d433ef8 →