[object Object]

← 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

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 →