PageSourceSearch

https://bikesontransit.org/js/map-view/filters/civic-action-filters.js

js bikesontransit.org collected 2026-09-27 01:48:21 UTC 12,486 bytes, 412 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
291}
292function escAttr(s) {
293  return escHtml(s).replace(/"/g, '&quot;');
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.