← back to Nationalrealestate

public/crcp-grid.js

379 lines

/* crcp-grid.js — reusable Amazon-style faceted grid engine for the CRE fleet.
 * Config-driven, theme-agnostic (inherits the host page's CSS vars via cg-grid.css).
 * One page = one CrcpGrid.mount(opts) call. Mirrors the proven condos.html engine so
 * every list page gets the SAME behavior: left-rail facets, per-field show/hide toggles,
 * grid/list view toggle, density slider, live search, localStorage persistence.
 *
 * opts = {
 *   data:      [row,...],            // all rows (client-side faceting)
 *   fields:    [{k,l,def,col,badge,money,num,date,calc,suf,pct}],  // every data point (card rows + list cols)
 *   facets:    [{k,l,type:'chip'|'range',calc,order,max}],         // rail filters (auto-derived if omitted)
 *   mount:     '#cgMain',            // container for topbar + grid
 *   rail:      '#cgRail',            // container for the left rail
 *   title:     row => ({name, sub}),// card/list title + subline
 *   search:    row => 'text blob',   // searchable string
 *   badgeHtml: row => '<span>...'|'',// optional status badge html (card + list)
 *   storageKey:'fha',               // localStorage namespace
 *   cols:      3,                   // default density
 *   onRow:     row => ({cls}),      // optional per-row class hook (e.g. red flag)
 *   onRowClick:row => {},           // optional whole-row click handler (list + cards)
 *   sort:      {k:'score',dir:-1},  // default sort (USRE adaptation; persisted, select + th-click)
 * }
 * USRE adaptations (nationalrealestate): sort <select> in the topbar + clickable list-view
 * column headers (both persisted), density slider also drives list row padding/font via
 * --cg-dens, onRowClick hook. Engine otherwise identical to the CRCP original.
 */
(function (global) {
  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
  const $ = (sel, root) => (root || document).querySelector(sel);

  function fmt(v, f) {
    if (v == null || v === '') return '—';
    if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
    if (f.pct)   return (+v).toFixed(2) + '%';
    if (f.num)   return (+v).toLocaleString() + (f.suf || '');
    if (f.date)  { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
    return esc(v) + (f.suf || '');
  }

  function CrcpGrid(opts) {
    const O = opts;
    const KEY = O.storageKey || 'cg';
    const fields = O.fields;
    const data = O.data || [];
    const fval = (row, f) => f.calc ? f.calc(row) : row[f.k];

    // ---- field visibility (persisted) ----
    const VIS = (function () {
      let s = {}; try { s = JSON.parse(localStorage.getItem(KEY + 'Fields') || '{}'); } catch (e) {}
      const o = {}; fields.forEach(f => o[f.k] = (f.k in s) ? !!s[f.k] : !!f.def); return o;
    })();
    const saveVis = () => { try { localStorage.setItem(KEY + 'Fields', JSON.stringify(VIS)); } catch (e) {} };

    // ---- auto-derive facets if not provided ----
    let facets = O.facets;
    if (!facets) {
      facets = [];
      fields.forEach(f => {
        if (f.badge || f.k === 'title') return;
        const vals = data.map(r => fval(r, f)).filter(v => v != null && v !== '');
        if (!vals.length) return;
        const numeric = f.money || f.num || f.pct || vals.every(v => typeof v === 'number' || /^\d+(\.\d+)?$/.test(v));
        const uniq = new Set(vals.map(String));
        if (!numeric && uniq.size <= 24) facets.push({ k: f.k, l: f.l, type: 'chip', calc: f.calc });
        else if (numeric) facets.push({ k: f.k, l: f.l, type: 'range', calc: f.calc, money: f.money });
      });
    }

    // ---- sort state (persisted) ----
    let SORT = O.sort || null; // {k, dir:1|-1}; k='' = source order
    try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
    const saveSort = () => { try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e) {} };
    function sortRows(rows) {
      if (!SORT || !SORT.k) return rows;
      const f = fields.find(x => x.k === SORT.k);
      if (!f) return rows;
      const dir = SORT.dir || 1;
      return [...rows].sort((a, b) => {
        const va = fval(a, f), vb = fval(b, f);
        const na = va == null || va === '', nb = vb == null || vb === '';
        if (na && nb) return 0; if (na) return 1; if (nb) return -1; // nulls last either dir
        if (typeof va === 'number' || typeof vb === 'number' || f.money || f.num || f.pct)
          return ((+va) - (+vb)) * dir;
        return String(va).localeCompare(String(vb)) * dir;
      });
    }

    // ---- filter state ----
    const F = { q: '', chips: {}, ranges: {} };
    facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k] = new Set(); else F.ranges[fc.k] = { min: null, max: null }; });

    const gv = (row, fc) => fc.calc ? fc.calc(row) : row[fc.k];
    function pass(row) {
      for (const fc of facets) {
        if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set.size && !set.has(String(gv(row, fc)))) return false; }
        else { const r = F.ranges[fc.k], v = gv(row, fc);
          if (r.min != null && !(+v >= r.min)) return false;
          if (r.max != null && !(+v <= r.max)) return false; }
      }
      if (F.q) { const blob = (O.search ? O.search(row) : fields.map(f => fval(row, f)).join(' ')).toLowerCase(); if (!blob.includes(F.q)) return false; }
      return true;
    }

    // ---- URL sync: filters/search/sort <-> query string (shareable views) ----
    function readFromURL() {
      if (O.urlSync === false) return;
      const sp = new URLSearchParams(location.search);
      if (![...sp.keys()].length) return;
      facets.forEach(fc => {
        if (fc.type === 'chip') { const v = sp.get(fc.k); if (v) v.split(',').forEach(x => F.chips[fc.k].add(x)); }
        else {
          const mn = sp.get(fc.k + '_min'), mx = sp.get(fc.k + '_max');
          if (mn != null && mn !== '') F.ranges[fc.k].min = +mn;
          if (mx != null && mx !== '') F.ranges[fc.k].max = +mx;
        }
      });
      const q = sp.get('q'); if (q) F.q = q.toLowerCase();
      const so = sp.get('sort'); if (so) { const [k, d] = so.split(':'); if (k) SORT = { k, dir: d === 'asc' ? 1 : -1 }; }
    }
    function syncURL() {
      if (O.urlSync === false) return;
      const sp = new URLSearchParams();
      facets.forEach(fc => {
        if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set && set.size) sp.set(fc.k, [...set].join(',')); }
        else { const r = F.ranges[fc.k]; if (r) { if (r.min != null) sp.set(fc.k + '_min', r.min); if (r.max != null) sp.set(fc.k + '_max', r.max); } }
      });
      if (F.q) sp.set('q', F.q);
      if (SORT && SORT.k) sp.set('sort', SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc'));
      const qs = sp.toString();
      try { history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '') + location.hash); } catch (e) {}
    }

    // ---- rail ----
    function buildRail() {
      const rail = $(O.rail);
      let html = '';
      facets.forEach(fc => {
        if (fc.type === 'chip') {
          const counts = {};
          data.forEach(r => { const v = gv(r, fc); if (v != null && v !== '') counts[v] = (counts[v] || 0) + 1; });
          const ent = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, fc.max || 20);
          if (!ent.length) return;
          // render FROM F: mark active chips + auto-expand a section that has a live filter
          const set = F.chips[fc.k];
          const secCls = (set && set.size) ? 'cg-sec' : 'cg-sec collapsed';
          html += `<div class="${secCls}"><h4>${esc(fc.l)}</h4><div class="cg-chips" data-facet="${esc(fc.k)}">` +
            ent.map(([k, c]) => `<span class="cg-chip${set && set.has(String(k)) ? ' active' : ''}" data-v="${esc(k)}">${esc(k)}<span class="cg-ct">${c}</span></span>`).join('') + `</div></div>`;
        } else {
          const rg = F.ranges[fc.k] || {};
          const active = rg.min != null || rg.max != null;
          const secCls = active ? 'cg-sec' : 'cg-sec collapsed';
          const mn = rg.min != null ? ` value="${esc(rg.min)}"` : '';
          const mx = rg.max != null ? ` value="${esc(rg.max)}"` : '';
          html += `<div class="${secCls}"><h4>${esc(fc.l)}</h4><div class="cg-row2" data-facet="${esc(fc.k)}">` +
            `<div class="cg-fld"><label>Min</label><input type="number" class="cg-min" placeholder="any"${mn}></div>` +
            `<div class="cg-fld"><label>Max</label><input type="number" class="cg-max" placeholder="any"${mx}></div></div></div>`;
        }
      });
      html += `<div class="cg-sec"><h4>Fields — show / hide</h4><div class="cg-ftogs">` +
        fields.map(f => `<label class="cg-ftog"><input type="checkbox" data-fk="${esc(f.k)}" ${VIS[f.k] ? 'checked' : ''}><span>${esc(f.l)}</span>${f.col ? '<span class="cg-ct" title="List column">▤</span>' : ''}</label>`).join('') +
        `</div><div class="cg-mini"><button data-all="1">All</button><button data-none="1">None</button><button data-def="1">Default</button></div></div>`;
      rail.innerHTML = html;

      // wire facets
      rail.querySelectorAll('.cg-chips').forEach(box => box.addEventListener('click', e => {
        const c = e.target.closest('.cg-chip'); if (!c) return;
        const set = F.chips[box.dataset.facet]; const v = c.dataset.v;
        set.has(v) ? set.delete(v) : set.add(v); c.classList.toggle('active'); render();
      }));
      rail.querySelectorAll('.cg-row2').forEach(box => {
        const k = box.dataset.facet;
        box.querySelector('.cg-min').addEventListener('input', e => { F.ranges[k].min = e.target.value ? +e.target.value : null; render(); });
        box.querySelector('.cg-max').addEventListener('input', e => { F.ranges[k].max = e.target.value ? +e.target.value : null; render(); });
      });
      // field toggles
      rail.querySelector('.cg-ftogs').addEventListener('change', e => { const cb = e.target.closest('input[data-fk]'); if (!cb) return; VIS[cb.dataset.fk] = cb.checked; saveVis(); render(); });
      const mini = rail.querySelector('.cg-mini');
      mini.addEventListener('click', e => {
        const b = e.target.closest('button'); if (!b) return;
        if (b.dataset.all) fields.forEach(f => VIS[f.k] = true);
        else if (b.dataset.none) fields.forEach(f => VIS[f.k] = false);
        else if (b.dataset.def) fields.forEach(f => VIS[f.k] = !!f.def);
        saveVis(); buildRail(); render();
      });
      // collapsible headers
      rail.querySelectorAll('.cg-sec > h4').forEach(h => h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed')));
    }

    // ---- renderers ----
    function cards(rows) {
      const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
      return rows.map((r, i) => {
        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
        return `<div class="cg-card ${rc}" data-ri="${i}">` +
          `<div class="cg-name">${nm}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
          (O.badgeHtml ? O.badgeHtml(r) : '') +
          vis.map(f => `<div class="cg-r"><span class="cg-k">${esc(f.l)}</span><span>${fmt(fval(r, f), f)}</span></div>`).join('') +
          `</div>`;
      }).join('');
    }
    function list(rows) {
      const cols = fields.filter(f => f.col && VIS[f.k] && !f.badge);
      const arrow = k => (SORT && SORT.k === k) ? (SORT.dir < 0 ? ' ▼' : ' ▲') : '';
      const titleK = O.titleSortK || '';
      const head = `<th class="cg-stick cg-sortth" data-k="${esc(titleK)}">${esc((O.titleCol || 'Name'))}${arrow(titleK)}</th>` +
        cols.map(f => `<th class="cg-sortth" data-k="${esc(f.k)}">${esc(f.l)}${arrow(f.k)}</th>`).join('');
      const body = rows.map((r, i) => {
        const t = O.title ? O.title(r) : { name: '', sub: '' };
        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
        return `<tr class="${rc}" data-ri="${i}"><td class="cg-stick"><span class="cg-la">${nm}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
          cols.map(f => `<td>${fmt(fval(r, f), f)}</td>`).join('') + `</tr>`;
      }).join('');
      return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
    }

    // ---- active-filter summary bar (removable chips + clear all) ----
    function activeFilterHTML() {
      const parts = [];
      facets.forEach(fc => {
        if (fc.type === 'chip') {
          const set = F.chips[fc.k];
          if (set) set.forEach(v => parts.push(
            `<span class="cg-af" data-fk="${esc(fc.k)}" data-aft="chip" data-v="${esc(v)}">${esc(fc.l)}: ${esc(v)} <b>✕</b></span>`));
        } else {
          const r = F.ranges[fc.k];
          if (r && (r.min != null || r.max != null)) {
            const txt = (r.min != null && r.max != null) ? esc(r.min) + '–' + esc(r.max)
              : (r.min != null) ? '≥ ' + esc(r.min) : '≤ ' + esc(r.max);
            parts.push(`<span class="cg-af" data-fk="${esc(fc.k)}" data-aft="range">${esc(fc.l)}: ${txt} <b>✕</b></span>`);
          }
        }
      });
      if (F.q) parts.push(`<span class="cg-af" data-aft="q">“${esc(F.q)}” <b>✕</b></span>`);
      if (!parts.length) return '';
      return `<span class="cg-af-lbl">Filters</span>` + parts.join('') +
        `<button class="cg-af-clear" data-clearall="1">Clear all</button>` +
        `<button class="cg-af-copy" data-copy="1" title="Copy a shareable link to this filtered view">🔗 Copy link</button>`;
    }
    function clearFilter(el) {
      if (el.dataset.clearall != null) {
        facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k].clear(); else F.ranges[fc.k] = { min: null, max: null }; });
        F.q = ''; const q = $(O.mount + ' .cg-q'); if (q) q.value = '';
      } else if (el.dataset.aft === 'q') {
        F.q = ''; const q = $(O.mount + ' .cg-q'); if (q) q.value = '';
      } else if (el.dataset.aft === 'chip') {
        F.chips[el.dataset.fk].delete(el.dataset.v);
      } else if (el.dataset.aft === 'range') {
        F.ranges[el.dataset.fk] = { min: null, max: null };
      }
      buildRail(); render(); // buildRail re-renders the rail from F so it stays in sync
    }

    let VIEW = localStorage.getItem(KEY + 'View') || O.view || 'grid';
    const CAP = O.cap || 1500; // DOM render cap for perf; count still reflects the full match set
    let lastShown = [];
    function render() {
      const rows = sortRows(data.filter(pass));
      const shown = rows.length > CAP ? rows.slice(0, CAP) : rows;
      lastShown = shown;
      const g = $(O.mount + ' .cg-grid');
      if (!rows.length) {
        g.className = 'cg-grid';
        g.innerHTML = '<div class="cg-empty"><div class="cg-empty-ic">⌕</div>' +
          '<div class="cg-empty-t">No matches</div>' +
          '<div class="cg-empty-s">No results match the current filters.</div>' +
          '<button class="cg-af-clear cg-empty-btn" data-clearall="1">Clear all filters</button></div>';
      }
      else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = list(shown); }
      else { g.className = 'cg-grid'; g.innerHTML = cards(shown); }
      const cnt = $(O.mount + ' .cg-count');
      if (cnt) cnt.innerHTML = `<b>${rows.length.toLocaleString()}</b> of ${data.length.toLocaleString()}` + (rows.length > CAP ? ` <span style="opacity:.7">(first ${CAP.toLocaleString()} shown)</span>` : '');
      const sel = $(O.mount + ' .cg-sort');
      if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
      const ab = $(O.mount + ' .cg-active');
      if (ab) ab.innerHTML = activeFilterHTML();
      syncURL();
    }

    // ---- topbar ----
    function buildTop() {
      const m = $(O.mount);
      const sortable = fields.filter(f => f.col && !f.badge);
      const sortOpts = `<option value="">Sort…</option>` + sortable.map(f =>
        `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
      m.insertAdjacentHTML('afterbegin',
        `<div class="cg-top">` +
        `<button type="button" class="cg-filt-btn" aria-label="Filters">☰ Filters</button>` +
        `<input type="text" class="cg-q" placeholder="search…">` +
        `<select class="cg-sort" title="Sort">${sortOpts}</select>` +
        `<span class="cg-seg"><button data-view="grid" class="${VIEW === 'grid' ? 'active' : ''}">▦ Grid</button><button data-view="list" class="${VIEW === 'list' ? 'active' : ''}">☰ List</button></span>` +
        `<label class="cg-dens">Density <input type="range" class="cg-cols" min="1" max="12" step="1" value="${O.cols || 3}"></label>` +
        `<span class="cg-count"></span></div><div class="cg-active"></div><div class="cg-grid"></div>`);
      const q = m.querySelector('.cg-q');
      if (F.q) q.value = F.q; // reflect a search restored from the URL
      q.addEventListener('input', () => { clearTimeout(q._t); q._t = setTimeout(() => { F.q = q.value.trim().toLowerCase(); render(); }, 160); });
      m.querySelector('.cg-sort').addEventListener('change', e => {
        const v = e.target.value;
        if (!v) SORT = null;
        else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
        saveSort(); render();
      });
      m.querySelector('.cg-seg').addEventListener('click', e => { const b = e.target.closest('button[data-view]'); if (!b) return; VIEW = b.dataset.view; localStorage.setItem(KEY + 'View', VIEW); m.querySelectorAll('.cg-seg button').forEach(x => x.classList.toggle('active', x.dataset.view === VIEW)); render(); });
      const cols = m.querySelector('.cg-cols');
      const setCols = v => { m.style.setProperty('--cg-cols', v); m.style.setProperty('--cg-dens', v); localStorage.setItem(KEY + 'Cols', v); };
      cols.addEventListener('input', e => setCols(e.target.value));
      const sc = localStorage.getItem(KEY + 'Cols'); if (sc) { cols.value = sc; }
      setCols(cols.value);
      // mobile: "Filters" button opens the existing rail as an off-canvas drawer
      const filtBtn = m.querySelector('.cg-filt-btn');
      const railEl = $(O.rail);
      if (filtBtn && railEl) {
        const bd = document.createElement('div'); bd.className = 'cg-rail-backdrop'; document.body.appendChild(bd);
        const openRail = () => { railEl.classList.add('open'); bd.classList.add('open'); };
        const closeRail = () => { railEl.classList.remove('open'); bd.classList.remove('open'); };
        filtBtn.addEventListener('click', () => railEl.classList.contains('open') ? closeRail() : openRail());
        bd.addEventListener('click', closeRail);
        document.addEventListener('keydown', e => { if (e.key === 'Escape') closeRail(); });
      }
      // th-click sorting + whole-row click (delegated; grid re-renders under us)
      m.addEventListener('click', e => {
        const cp = e.target.closest('.cg-af-copy');
        if (cp) {
          const done = () => { const t = cp.textContent; cp.textContent = '✓ Copied'; setTimeout(() => cp.textContent = t, 1200); };
          if (navigator.clipboard) navigator.clipboard.writeText(location.href).then(done, done); else done();
          return;
        }
        const af = e.target.closest('.cg-af, .cg-af-clear');
        if (af) { clearFilter(af); return; }
        const th = e.target.closest('.cg-sortth');
        if (th && th.dataset.k) {
          const k = th.dataset.k;
          SORT = (SORT && SORT.k === k) ? { k, dir: -SORT.dir } : { k, dir: -1 };
          saveSort(); render(); return;
        }
        if (O.onRowClick && !e.target.closest('a')) {
          const tr = e.target.closest('tr[data-ri]');
          if (tr) { O.onRowClick(lastShown[+tr.dataset.ri], tr); return; }
          const card = e.target.closest('.cg-card[data-ri]');
          if (card) O.onRowClick(lastShown[+card.dataset.ri], card);
        }
      });
    }

    // Export the CURRENTLY filtered + sorted rows (uncapped) as a CSV Blob — so a
    // download reflects exactly what the user filtered to, not the whole dataset.
    function exportCSV(opts) {
      opts = opts || {};
      const rows = sortRows(data.filter(pass));
      const cols = fields.filter(f => !f.badge && f.k !== 'name'); // 'name' handled as the title column
      const csvEsc = v => { if (v == null) return ''; v = String(v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
      const header = ['Name'].concat(cols.map(f => f.l));
      const lines = [header.map(csvEsc).join(',')];
      rows.forEach(r => {
        const t = O.title ? O.title(r) : { name: r.name || '' };
        lines.push([csvEsc(t.name)].concat(cols.map(f => csvEsc(fval(r, f)))).join(','));
      });
      const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = opts.filename || 'export.csv';
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
      return rows.length;
    }

    readFromURL(); buildTop(); buildRail(); render();
    return {
      render,
      setData(d) { O.data.length = 0; d.forEach(x => O.data.push(x)); buildRail(); render(); },
      exportCSV,
      filteredCount() { return data.filter(pass).length; },
    };
  }

  global.CrcpGrid = { mount: opts => new CrcpGrid(opts) };
})(window);