1// html/js/map-view/filters/civic-action-filters.js 2// 3// Floating filter panel for civic-action features (those with a 4// framework_id property). Renders a small bottom-left card with: 5// - Date range (two date inputs) 6// - Dimension multi-select (checkbox per framework dimension) 7// - Minimum dollar threshold slider 8// 9// Writes the active filter into window.viewOptions.civicActionFilters 10// and calls refreshMapData() to re-bake. Persists state in the URL 11// query string so links can pre-filter. 12// 13// Auto-mounts when the project has at least one feature with 14// framework_id set. 15 16import { refreshMapData } from '../drawing/layers.js'; 17 18const PANEL_ID = 'civic-action-filter-panel'; 19const STORAGE_KEY = 'civicActionFiltersV1'; 20 21let _panelEl = null; 22let _dimensions = []; 23let _state = null; 24 25const ALL_DIMENSIONS = '__all__'; 26 27/* ---------------- Public ---------------- */ 28 29/** 30 * Detect Civic Action features and, if found, mount the filter panel. 31 * Call this once after the project's features are loaded. 32 */ 33export async function mountCivicActionFilters() { 34 // Project-level opt-out (Project Edit â Map Overlays â Hide Filter Actions panel) 35 if (window.projectSettings?.hide_civic_action_filters) return; 36 37 const wm = window.wmState; 38 if (!wm || !Array.isArray(wm.features)) return; 39 40 // Detect at least one Civic Action feature 41 const hasCivicAction = wm.features.some(f => 42 (f.properties && f.properties.framework_id) || 43 (f.latestAnswers && f.latestAnswers.framework_id) 44 ); 45 if (!hasCivicAction) return; 46 47 // Pull the project's framework dimensions for the multi-select. 48 const fwId = inferFrameworkId(wm.features); 49 if (!fwId) return; 50 51 try { 52 const res = await fetch(`/api/frameworks/${encodeURIComponent(fwId)}`); 53 if (res.ok) { 54 const fw = await res.json(); 55 if (Array.isArray(fw.dimensions) && fw.dimensions.length) { 56 _dimensions = fw.dimensions; 57 } 58 } 59 } catch { /* fall through */ } 60 if (!_dimensions.length) { 61 // Derive from the features themselves as a fallback. 62 const seen = new Map(); 63 for (const f of wm.features) { 64 const slug = (f.properties || {}).framework_dimension_slug 65 || (f.latestAnswers || {}).framework_dimension_slug; 66 const label = (f.properties || {}).framework_dimension_label 67 || (f.latestAnswers || {}).framework_dimension_label 68 || slug; 69 if (slug && !seen.has(slug)) seen.set(slug, { slug, display_name: label, color: '#888' }); 70 } 71 _dimensions = [...seen.values()].sort((a, b) => a.display_name.localeCompare(b.display_name)); 72 } 73 if (!_dimensions.length) return; 74 75 _state = readState(); 76 applyState(_state, /* refresh */ false); 77 renderPanel(); 78 // Apply filters once the panel is mounted to reflect any URL-persisted state. 79 if (anyFilterActive(_state)) { 80 refreshMapData({ force: true }).catch(() => {}); 81 } 82} 83 84/* ---------------- State ---------------- */ 85 86function defaultState() { 87 return { 88 dateFrom: '', 89 dateTo: '', 90 dimensions: [], 91 minDollars: 0, 92 open: false, 93 }; 94} 95 96function readState() { 97 // URL first (so deep links work), then localStorage as a fallback. 98 try { 99 const url = new URL(window.location.href); 100 if (url.searchParams.has('caFilter')) { 101 const decoded = JSON.parse(atob(url.searchParams.get('caFilter'))); 102 return { ...defaultState(), ...decoded }; 103 } 104 } catch { /* ignore */ } 105 try { 106 const cached = sessionStorage.getItem(STORAGE_KEY); 107 if (cached) return { ...defaultState(), ...JSON.parse(cached) }; 108 } catch { /* ignore */ } 109 return defaultState(); 110} 111 112function persistState(state) { 113 try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { /* ignore */ } 114 // Mirror to URL only when at least one filter is active to keep the bar clean. 115 try { 116 const url = new URL(window.location.href); 117 if (anyFilterActive(state)) { 118 const enc = btoa(JSON.stringify({ 119 dateFrom: state.dateFrom, 120 dateTo: state.dateTo, 121 dimensions: state.dimensions, 122 minDollars: state.minDollars, 123 })); 124 url.searchParams.set('caFilter', enc); 125 } else { 126 url.searchParams.delete('caFilter'); 127 } 128 window.history.replaceState(null, '', url.toString()); 129 } catch { /* ignore */ } 130} 131 132function anyFilterActive(s) { 133 return !!(s.dateFrom || s.dateTo || (s.dimensions && s.dimensions.length) || s.minDollars); 134} 135 136function applyState(state, refresh = true) { 137 window.viewOptions = window.viewOptions || {}; 138 window.viewOptions.civicActionFilters = { 139 dateFrom: state.dateFrom || '', 140 dateTo: state.dateTo || '', 141 dimensions: state.dimensions || [], 142 minDollars: Number.isFinite(state.minDollars) ? Number(state.minDollars) : 0, 143 }; 144 persistState(state); 145 if (refresh) refreshMapData({ force: true }).catch(() => {}); 146} 147 148/* ---------------- Inference ---------------- */ 149 150function inferFrameworkId(features) { 151 for (const f of features) { 152 const id = (f.properties || {}).framework_id 153 || (f.latestAnswers || {}).framework_id; 154 if (id) return id; 155 } 156 return null; 157} 158 159/* ---------------- Rendering ---------------- */ 160 161function renderPanel() { 162 if (_panelEl) _panelEl.remove(); 163 164 const wrap = document.createElement('div'); 165 wrap.id = PANEL_ID; 166 wrap.className = 'caf-wrap'; 167 wrap.innerHTML = ` 168 <button class="caf-toggle" type="button" aria-expanded="${_state.open ? 'true' : 'false'}"> 169 <i class="fas fa-filter"></i>
170 <span class="caf-toggle-label">Filter actions</span> 171 <span class="caf-active-count" hidden></span> 172 </button> 173 <div class="caf-body" ${_state.open ? '' : 'hidden'}> 174 <div class="caf-row"> 175 <label>From</label> 176 <input type="date" class="caf-date-from" value="${escAttr(_state.dateFrom)}"> 177 <label>To</label> 178 <input type="date" class="caf-date-to" value="${escAttr(_state.dateTo)}"> 179 </div> 180 <div class="caf-row caf-dollar-row"> 181 <label>Min $ <span class="caf-dollar-display">${formatDollars(_state.minDollars)}</span></label> 182 <input type="range" class="caf-dollars" min="0" max="10000000" step="50000" value="${Number(_state.minDollars) || 0}"> 183 </div> 184 <div class="caf-row caf-dim-row"> 185 <label>Dimensions</label> 186 <div class="caf-dim-grid"> 187 ${_dimensions.map(d => ` 188 <label class="caf-dim-item"> 189 <input type="checkbox" class="caf-dim-cb" value="${escAttr(d.slug)}" 190 ${_state.dimensions.includes(d.slug) ? 'checked' : ''}> 191 <span class="caf-dim-dot" style="background:${escAttr(d.color || '#888')};"></span> 192 ${escHtml(d.display_name)} 193 </label> 194 `).join('')} 195 </div> 196 </div> 197 <div class="caf-row caf-actions"> 198 <button type="button" class="caf-clear">Clear all</button> 199 </div> 200 </div> 201 `; 202 document.body.appendChild(wrap); 203 _panelEl = wrap; 204 205 ensureStyles(); 206 wireEvents(); 207 updateActiveCountBadge(); 208} 209 210function wireEvents() { 211 const root = _panelEl; 212 if (!root) return; 213 214 root.querySelector('.caf-toggle').addEventListener('click', () => { 215 _state.open = !_state.open; 216 const body = root.querySelector('.caf-body'); 217 if (_state.open) body.removeAttribute('hidden'); 218 else body.setAttribute('hidden', ''); 219 root.querySelector('.caf-toggle').setAttribute('aria-expanded', _state.open ? 'true' : 'false'); 220 }); 221 222 const fromEl = root.querySelector('.caf-date-from'); 223 const toEl = root.querySelector('.caf-date-to'); 224 const dollarEl = root.querySelector('.caf-dollars'); 225 const dollarDisplay = root.querySelector('.caf-dollar-display'); 226 const dimCbs = root.querySelectorAll('.caf-dim-cb'); 227 const clearBtn = root.querySelector('.caf-clear'); 228 229 fromEl.addEventListener('change', () => { 230 _state.dateFrom = fromEl.value; 231 applyState(_state); 232 updateActiveCountBadge(); 233 }); 234 toEl.addEventListener('change', () => { 235 _state.dateTo = toEl.value; 236 applyState(_state); 237 updateActiveCountBadge(); 238 }); 239 dollarEl.addEventListener('input', () => { 240 _state.minDollars = Number(dollarEl.value) || 0; 241 dollarDisplay.textContent = formatDollars(_state.minDollars); 242 }); 243 dollarEl.addEventListener('change', () => { 244 applyState(_state); 245 updateActiveCountBadge(); 246 }); 247 dimCbs.forEach(cb => { 248 cb.addEventListener('change', () => { 249 const checked = [...dimCbs].filter(c => c.checked).map(c => c.value); 250 _state.dimensions = checked; 251 applyState(_state); 252 updateActiveCountBadge(); 253 }); 254 }); 255 clearBtn.addEventListener('click', () => { 256 _state = defaultState(); 257 _state.open = true; 258 renderPanel(); 259 applyState(_state); 260 }); 261} 262 263function updateActiveCountBadge() { 264 const badge = _panelEl?.querySelector('.caf-active-count'); 265 if (!badge) return; 266 let count = 0; 267 if (_state.dateFrom) count++; 268 if (_state.dateTo) count++; 269 if (_state.dimensions && _state.dimensions.length) count += 1; 270 if (_state.minDollars) count++; 271 if (count > 0) { 272 badge.textContent = String(count); 273 badge.hidden = false; 274 } else { 275 badge.hidden = true; 276 } 277} 278 279/* ---------------- Utilities ---------------- */ 280 281function formatDollars(n) { 282 const v = Number(n) || 0; 283 if (v === 0) return 'any'; 284 if (v >= 1_000_000) return '$' + (v / 1_000_000).toFixed(v >= 10_000_000 ? 0 : 1) + 'M'; 285 if (v >= 1_000) return '$' + Math.round(v / 1_000) + 'K'; 286 return '$' + v.toLocaleString(); 287} 288 289function escHtml(s) { 290 return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 291} 292function escAttr(s) { 293 return escHtml(s).replace(/"/g, '"'); 294} 295 296let _stylesAdded = false;
297function ensureStyles() { 298 if (_stylesAdded) return; 299 _stylesAdded = true; 300 const s = document.createElement('style'); 301 s.textContent = ` 302 .caf-wrap { 303 position: absolute; 304 bottom: 16px; 305 left: 16px; 306 z-index: 9000; 307 background: #fff; 308 border: 1px solid #d0d0d0; 309 border-radius: 8px; 310 box-shadow: 0 4px 14px rgba(0,0,0,0.18); 311 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 312 font-size: 13px; 313 max-width: 320px; 314 min-width: 220px; 315 } 316 .caf-toggle { 317 background: #fafafa; 318 border: 0; 319 border-radius: 8px 8px 0 0; 320 padding: 8px 12px; 321 width: 100%; 322 text-align: left; 323 cursor: pointer; 324 display: flex; 325 align-items: center; 326 gap: 8px; 327 font-weight: 500; 328 } 329 .caf-toggle:hover { background: #f0f0f0; } 330 .caf-toggle-label { flex: 1; } 331 .caf-active-count { 332 background: #177AB5; 333 color: #fff; 334 border-radius: 12px; 335 padding: 1px 7px; 336 font-size: 11px; 337 } 338 .caf-body { padding: 10px 12px 12px; border-top: 1px solid #eee; } 339 .caf-row { 340 display: flex; 341 gap: 6px; 342 align-items: center; 343 margin-bottom: 8px; 344 flex-wrap: wrap; 345 } 346 .caf-row label { 347 font-size: 11.5px; 348 text-transform: uppercase; 349 letter-spacing: 0.04em; 350 color: #555; 351 } 352 .caf-row input[type="date"] { 353 flex: 1 1 110px; 354 padding: 3px 6px; 355 border: 1px solid #c8c8c8; 356 border-radius: 4px; 357 font-size: 12px; 358 } 359 .caf-dollar-row { 360 flex-direction: column; 361 align-items: stretch; 362 } 363 .caf-dollar-row label { 364 display: flex; 365 justify-content: space-between; 366 align-items: center; 367 text-transform: none; 368 letter-spacing: 0; 369 color: #444; 370 font-size: 12px; 371 } 372 .caf-dollar-display { font-weight: 600; } 373 .caf-dollars { width: 100%; } 374 .caf-dim-row { flex-direction: column; align-items: stretch; } 375 .caf-dim-grid { 376 display: grid; 377 grid-template-columns: 1fr 1fr; 378 gap: 4px 10px; 379 } 380 .caf-dim-item { 381 display: flex; 382 align-items: center; 383 gap: 5px; 384 font-size: 12px; 385 text-transform: none; 386 letter-spacing: 0; 387 color: #333; 388 font-weight: 400; 389 cursor: pointer; 390 } 391 .caf-dim-dot { 392 width: 8px; 393 height: 8px; 394 border-radius: 50%; 395 flex-shrink: 0; 396 } 397 .caf-actions { 398 justify-content: flex-end; 399 margin-bottom: 0; 400 } 401 .caf-clear { 402 background: transparent; 403 border: 1px solid #ccc; 404 border-radius: 4px; 405 padding: 3px 10px; 406 font-size: 12px; 407 cursor: pointer; 408 } 409 .caf-clear:hover { background: #f3f3f3; } 410 `; 411 document.head.appendChild(s); 412}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.