← back to La Socrata Ingester
Viewer: column show/hide menu, / search shortcut, density+columns in deep-link
d7a05ae82224e56db07d377fc13c6c2b8e80e178 · 2026-08-12 11:41:20 -0700 · Steve Abrams
- ▦ Columns popover (List view): checkbox per column to show/hide, show-all +
reset actions, always keeps >=1 column visible; persists to localStorage.
- Keyboard: '/' focuses the search box, Esc closes the modal/popover; placeholder
hints the shortcut.
- Deep-link now also encodes density (d=) and the visible column set+order
(cols=) so a shared URL restores the exact table layout. Verified round-trip.
Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M viewer/public/index.html
Diff
commit d7a05ae82224e56db07d377fc13c6c2b8e80e178
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 11:41:20 2026 -0700
Viewer: column show/hide menu, / search shortcut, density+columns in deep-link
- ▦ Columns popover (List view): checkbox per column to show/hide, show-all +
reset actions, always keeps >=1 column visible; persists to localStorage.
- Keyboard: '/' focuses the search box, Esc closes the modal/popover; placeholder
hints the shortcut.
- Deep-link now also encodes density (d=) and the visible column set+order
(cols=) so a shared URL restores the exact table layout. Verified round-trip.
Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
viewer/public/index.html | 56 ++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 52 insertions(+), 4 deletions(-)
diff --git a/viewer/public/index.html b/viewer/public/index.html
index d00a083..94bffa4 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -114,13 +114,24 @@
.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--teal);border-radius:50%;
margin:0 auto 10px;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
+ /* column show/hide popover */
+ .colspop{position:absolute;top:34px;right:0;z-index:70;background:#fff;border:1px solid var(--line);border-radius:8px;
+ box-shadow:0 8px 24px rgba(16,24,40,.14);padding:6px;min-width:186px;max-height:340px;overflow:auto}
+ .colspop .hd{display:flex;justify-content:space-between;align-items:center;padding:4px 8px 6px;border-bottom:1px solid var(--line);margin-bottom:4px}
+ .colspop .hd span:first-child{color:var(--muted);font-size:11px;letter-spacing:.05em}
+ .colspop .hd a{color:var(--accent);font-size:11px;cursor:pointer;text-decoration:none}
+ .colspop .hd a:hover{text-decoration:underline}
+ .colspop label{display:flex;align-items:center;gap:7px;padding:4px 8px;font-size:12px;cursor:pointer;border-radius:6px;white-space:nowrap}
+ .colspop label:hover{background:#f0f3f5}
+ kbd{font:11px/1 ui-monospace,Menlo,monospace;background:#eef1f4;border:1px solid var(--line);border-bottom-width:2px;
+ border-radius:4px;padding:1px 5px;color:#495057}
</style>
</head>
<body>
<div class="app">
<header>
<h1>LA <b>Building Permits</b></h1>
- <input id="q" type="search" placeholder="Search address…" autocomplete="off">
+ <input id="q" type="search" placeholder="Search address… (press / )" autocomplete="off">
<span class="count" id="count">…</span>
<span class="grow"></span>
<div class="views" id="views">
@@ -135,6 +146,7 @@
<option value="work_type">Work type</option>
</select></span>
<span class="ctl" id="densctl">Density <input id="density" type="range" min="26" max="46" value="34"></span>
+ <span class="ctl" id="colswrap" style="position:relative"><button id="colsbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Show / hide table columns">▦ Columns</button><div id="colspop" class="colspop hidden"></div></span>
<span class="ctl"><button id="csv" class="loadmore" style="margin:0;padding:5px 12px" title="Download the current filtered + sorted results as a CSV (up to 5,000 rows)">⬇ CSV</button></span>
<span class="ctl"><button id="copylink" class="loadmore" style="margin:0;padding:5px 12px" title="Copy a shareable link to this exact view (filters + sort + view + color)">🔗 Copy link</button></span>
<span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
@@ -207,8 +219,9 @@ const ALLCOLS = [
];
let colOrder = LS.get('cols', ALLCOLS.map(c=>c.key));
if(!Array.isArray(colOrder)||colOrder.length!==ALLCOLS.length) colOrder = ALLCOLS.map(c=>c.key);
+let colHidden = new Set(LS.get('hidden', []));
const colDef = k => ALLCOLS.find(c=>c.key===k);
-const cols = () => colOrder.map(colDef).filter(Boolean);
+const cols = () => colOrder.map(colDef).filter(c=>c && !colHidden.has(c.key));
let state = {
filters:{}, sort:LS.get('sort',{col:'lead_score',dir:'desc'}), page:1, rows:[], total:0,
@@ -380,6 +393,9 @@ function syncURL(){ // mirror filters+sort+view+color into the URL so any view i
if(state.sort&&state.sort.col){ p.set('sort',state.sort.col); p.set('dir',state.sort.dir); }
if(state.view&&state.view!=='list') p.set('view',state.view);
if(state.colorBy&&state.colorBy!=='lead_score') p.set('color',state.colorBy);
+ const dens=$('#density').value; if(dens && dens!=='34') p.set('d',dens); // density
+ const visKeys=cols().map(c=>c.key), defKeys=ALLCOLS.map(c=>c.key); // visible column order (encodes both hidden + reorder)
+ if(visKeys.join(',')!==defKeys.join(',')) p.set('cols',visKeys.join(','));
const s=p.toString();
try{ history.replaceState(null,'', s?('?'+s):location.pathname); }catch(e){}
}
@@ -389,6 +405,8 @@ function render(){
$('#gridview').classList.toggle('hidden', state.view!=='grid');
$('#mapview').classList.toggle('hidden', state.view!=='map');
$('#densctl').style.display = state.view==='map'?'none':'flex';
+ $('#colswrap').style.display = state.view==='list'?'inline-flex':'none'; // columns menu is list-only
+ if(state.view!=='list') $('#colspop').classList.add('hidden');
if(state.view==='list'){ renderHead(); renderBody(); }
else if(state.view==='grid') renderCards();
else renderMap();
@@ -417,7 +435,7 @@ $('#mx').onclick=$('#mbg').onclick=()=>$('#mbg').style.display=$('#modal').style
function reload(){ state.page=1; loadFacets(); loadRows(false); }
$('#more').onclick=$('#more2').onclick=()=>{ state.page++; loadRows(true); };
let t; $('#q').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{state.filters.q=e.target.value.trim();reload();},280);};
-$('#density').oninput=e=>{document.documentElement.style.setProperty('--rowh',e.target.value+'px');const c=Math.max(2,Math.round((72-e.target.value)/8));document.documentElement.style.setProperty('--cols',c);LS.set('density',e.target.value);};
+$('#density').oninput=e=>{document.documentElement.style.setProperty('--rowh',e.target.value+'px');const c=Math.max(2,Math.round((72-e.target.value)/8));document.documentElement.style.setProperty('--cols',c);LS.set('density',e.target.value);syncURL();};
$('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
// ---- CSV export of the current filtered + sorted view (client-side, capped) ----
const CSV_CAP = 5000; // hard ceiling; if the filtered total exceeds this we export the top N and say so
@@ -465,6 +483,32 @@ $('#views').querySelectorAll('button').forEach(b=>b.onclick=()=>{
});
$('#colorby').onchange=e=>{ state.colorBy=e.target.value; LS.set('colorby',state.colorBy); render(); };
+// ---- column show/hide popover ----
+function renderColsMenu(){
+ const pop=$('#colspop');
+ pop.innerHTML = `<div class="hd"><span>COLUMNS</span><span><a id="colall">show all</a> · <a id="colreset">reset</a></span></div>`+
+ colOrder.map(k=>{ const c=colDef(k); if(!c) return ''; const on=!colHidden.has(k);
+ return `<label><input type="checkbox" data-k="${k}" ${on?'checked':''}>${escapeHtml(c.label)}</label>`; }).join('');
+ pop.querySelectorAll('input[type=checkbox]').forEach(cb=>cb.onchange=()=>{
+ const k=cb.dataset.k;
+ if(cb.checked) colHidden.delete(k);
+ else { if(cols().length<=1){ cb.checked=true; return; } colHidden.add(k); } // always keep >=1 column visible
+ LS.set('hidden',[...colHidden]); renderHead(); renderBody(); syncURL();
+ });
+ $('#colall').onclick=()=>{ colHidden.clear(); LS.set('hidden',[]); renderColsMenu(); renderHead(); renderBody(); syncURL(); };
+ $('#colreset').onclick=()=>{ colOrder=ALLCOLS.map(c=>c.key); colHidden.clear(); LS.set('cols',colOrder); LS.set('hidden',[]); renderColsMenu(); renderHead(); renderBody(); syncURL(); };
+}
+$('#colsbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#colspop');
+ if(pop.classList.contains('hidden')){ renderColsMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
+document.addEventListener('click',e=>{ if(!e.target.closest('#colswrap')) $('#colspop').classList.add('hidden'); });
+
+// ---- keyboard: "/" focuses search, Esc closes modal/popover ----
+document.addEventListener('keydown',e=>{
+ const tag=(e.target.tagName||'').toLowerCase(), typing=tag==='input'||tag==='select'||tag==='textarea';
+ if(e.key==='/' && !typing){ e.preventDefault(); $('#q').focus(); $('#q').select(); }
+ else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
+});
+
function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
function escapeAttr(s){return escapeHtml(s).replace(/'/g,''')}
@@ -474,8 +518,12 @@ function escapeAttr(s){return escapeHtml(s).replace(/'/g,''')}
if(u.get('color')) state.colorBy=u.get('color');
['q','era','work_type','building_type','status','cd','zip','min_value','since'].forEach(k=>{ if(u.get(k)) state.filters[k]=u.get(k); });
if(u.get('sort')) state.sort={col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'};
+ if(u.get('cols')){ // deep-linked visible column set + order
+ const vis=u.get('cols').split(',').filter(k=>colDef(k));
+ if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); }
+ }
if(state.filters.q) $('#q').value=state.filters.q;
- const d=LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px');
+ const d=u.get('d')||LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px');
document.documentElement.style.setProperty('--cols',Math.max(2,Math.round((72-d)/8))); $('#density').value=d;
$('#colorby').value=state.colorBy;
$('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x.dataset.v===state.view));
← 0d560bd auto-data-snapshot: 2026-08-12T11:28:02 (1 data files) — pac
·
back to La Socrata Ingester
·
Viewer: saved views — named snapshots of the full view state bcf4edb →