feat: initial release of FlockRadar driving app and national transparency directory for flockradar.org
This commit is contained in:
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* FLOCK SAFETY TRANSPARENCY PORTALS DIRECTORY
|
||||
* Client Application Logic
|
||||
* High-performance search, filtering, pagination, modal inspector, and CSV export.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// --- APPLICATION STATE ---
|
||||
const state = {
|
||||
allPortals: [],
|
||||
orgsLookup: [],
|
||||
summary: {},
|
||||
filteredPortals: [],
|
||||
currentPage: 1,
|
||||
pageSize: 24,
|
||||
currentView: 'cards', // 'cards' or 'table'
|
||||
searchQuery: '',
|
||||
selectedState: 'ALL',
|
||||
selectedType: 'ALL',
|
||||
auditOnly: false,
|
||||
sortBy: 'cameras-desc',
|
||||
activeModalPortal: null
|
||||
};
|
||||
|
||||
// --- DOM ELEMENTS ---
|
||||
const el = {
|
||||
// Search & Filters
|
||||
searchInput: document.getElementById('search-input'),
|
||||
clearSearchBtn: document.getElementById('clear-search-btn'),
|
||||
stateFilter: document.getElementById('state-filter'),
|
||||
typeFilter: document.getElementById('type-filter'),
|
||||
sortSelect: document.getElementById('sort-select'),
|
||||
auditToggle: document.getElementById('audit-toggle'),
|
||||
btnResetAll: document.getElementById('btn-reset-all'),
|
||||
stateChipsScroll: document.getElementById('state-chips-scroll'),
|
||||
|
||||
// Stats Counters
|
||||
statPortals: document.getElementById('stat-portals'),
|
||||
statCameras: document.getElementById('stat-cameras'),
|
||||
statVehicles: document.getElementById('stat-vehicles'),
|
||||
statSearches: document.getElementById('stat-searches'),
|
||||
statHits: document.getElementById('stat-hits'),
|
||||
|
||||
// Views & Results
|
||||
resultsCounter: document.getElementById('results-counter'),
|
||||
showingCount: document.getElementById('showing-count'),
|
||||
totalCount: document.getElementById('total-count'),
|
||||
portalsGrid: document.getElementById('portals-grid'),
|
||||
tableContainer: document.getElementById('table-container'),
|
||||
tableBody: document.getElementById('table-body'),
|
||||
paginationControls: document.getElementById('pagination-controls'),
|
||||
viewCardsBtn: document.getElementById('view-cards-btn'),
|
||||
viewTableBtn: document.getElementById('view-table-btn'),
|
||||
btnExportCsv: document.getElementById('btn-export-csv'),
|
||||
|
||||
// Modal Elements
|
||||
agencyModal: document.getElementById('agency-modal'),
|
||||
modalCloseBtn: document.getElementById('modal-close-btn'),
|
||||
modalAgencyName: document.getElementById('modal-agency-name'),
|
||||
modalAgencyLocation: document.getElementById('modal-agency-location'),
|
||||
modalAgencySlug: document.getElementById('modal-agency-slug'),
|
||||
modalPortalUrl: document.getElementById('modal-portal-url'),
|
||||
modalStatCameras: document.getElementById('modal-stat-cameras'),
|
||||
modalStatVehicles: document.getElementById('modal-stat-vehicles'),
|
||||
modalStatSearches: document.getElementById('modal-stat-searches'),
|
||||
modalStatHits: document.getElementById('modal-stat-hits'),
|
||||
modalReasonsSection: document.getElementById('modal-reasons-section'),
|
||||
modalReasonsList: document.getElementById('modal-reasons-list'),
|
||||
modalSharingCount: document.getElementById('modal-sharing-count'),
|
||||
modalSharingSearch: document.getElementById('modal-sharing-search'),
|
||||
modalSharingBox: document.getElementById('modal-sharing-box'),
|
||||
modalProhibitedText: document.getElementById('modal-prohibited-text'),
|
||||
modalGoogleContractLink: document.getElementById('modal-google-contract-link'),
|
||||
modalCopyLinkBtn: document.getElementById('modal-copy-link-btn'),
|
||||
|
||||
// Toast Container
|
||||
toastContainer: document.getElementById('toast-container')
|
||||
};
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
function init() {
|
||||
if (!window.FLOCK_DATA || !window.FLOCK_DATA.portals) {
|
||||
showToast('Error: Dataset failed to load.', 'error');
|
||||
console.error('FLOCK_DATA not found');
|
||||
return;
|
||||
}
|
||||
|
||||
state.allPortals = window.FLOCK_DATA.portals || [];
|
||||
state.orgsLookup = window.FLOCK_DATA.orgs || [];
|
||||
state.summary = window.FLOCK_DATA.summary || {};
|
||||
|
||||
// Restore saved view preference
|
||||
const savedView = localStorage.getItem('flock_dir_view');
|
||||
if (savedView === 'table' || savedView === 'cards') {
|
||||
state.currentView = savedView;
|
||||
}
|
||||
|
||||
renderHeroStats();
|
||||
populateStateFilter();
|
||||
renderStateChips();
|
||||
bindEvents();
|
||||
|
||||
// Initial Filter & Render
|
||||
applyFilters();
|
||||
updateViewMode();
|
||||
|
||||
// Check URL Hash for deep link
|
||||
checkUrlHash();
|
||||
}
|
||||
|
||||
// --- HERO STATS FORMATTING ---
|
||||
function renderHeroStats() {
|
||||
const s = state.summary;
|
||||
if (!s) return;
|
||||
|
||||
if (s.total_portals_found) {
|
||||
el.statPortals.textContent = s.total_portals_found.toLocaleString();
|
||||
}
|
||||
if (s.total_cameras) {
|
||||
el.statCameras.textContent = s.total_cameras.toLocaleString() + '+';
|
||||
}
|
||||
if (s.total_vehicles_captured) {
|
||||
const millions = (s.total_vehicles_captured / 1000000).toFixed(1);
|
||||
el.statVehicles.textContent = millions + 'M';
|
||||
}
|
||||
if (s.total_searches) {
|
||||
el.statSearches.textContent = s.total_searches.toLocaleString();
|
||||
}
|
||||
if (s.total_hotlist_hits) {
|
||||
const millions = (s.total_hotlist_hits / 1000000).toFixed(1);
|
||||
el.statHits.textContent = millions + 'M+';
|
||||
}
|
||||
}
|
||||
|
||||
// --- POPULATE STATE SELECTOR & CHIPS ---
|
||||
function populateStateFilter() {
|
||||
const stateCounts = {};
|
||||
state.allPortals.forEach(p => {
|
||||
const st = p.state || 'OTHER';
|
||||
stateCounts[st] = (stateCounts[st] || 0) + 1;
|
||||
});
|
||||
|
||||
const sortedStates = Object.keys(stateCounts).sort();
|
||||
|
||||
// Clear existing options except ALL
|
||||
el.stateFilter.innerHTML = `<option value="ALL">All States (${state.allPortals.length} portals)</option>`;
|
||||
|
||||
sortedStates.forEach(st => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = st;
|
||||
opt.textContent = `${getStateFullName(st)} (${st}) — ${stateCounts[st]} portals`;
|
||||
el.stateFilter.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStateChips() {
|
||||
const stateCounts = {};
|
||||
state.allPortals.forEach(p => {
|
||||
const st = p.state || 'OTHER';
|
||||
stateCounts[st] = (stateCounts[st] || 0) + 1;
|
||||
});
|
||||
|
||||
// Top states by portal count
|
||||
const topStates = Object.entries(stateCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 16);
|
||||
|
||||
let chipsHtml = `
|
||||
<button class="state-chip ${state.selectedState === 'ALL' ? 'active' : ''}" data-state="ALL">
|
||||
<span>All States</span>
|
||||
<span class="chip-count">(${state.allPortals.length})</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
topStates.forEach(([st, count]) => {
|
||||
const isActive = state.selectedState === st;
|
||||
chipsHtml += `
|
||||
<button class="state-chip ${isActive ? 'active' : ''}" data-state="${st}">
|
||||
<span>${st}</span>
|
||||
<span class="chip-count">(${count})</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
el.stateChipsScroll.innerHTML = chipsHtml;
|
||||
}
|
||||
|
||||
// --- STATE FULL NAME HELPER ---
|
||||
const stateNames = {
|
||||
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
||||
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
|
||||
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
||||
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
|
||||
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri',
|
||||
MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey',
|
||||
NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio',
|
||||
OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina',
|
||||
SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont',
|
||||
VA: 'Virginia', WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming'
|
||||
};
|
||||
|
||||
function getStateFullName(abbr) {
|
||||
return stateNames[abbr] || abbr;
|
||||
}
|
||||
|
||||
// --- FILTER & SORT LOGIC ---
|
||||
function applyFilters() {
|
||||
const q = state.searchQuery.trim().toLowerCase();
|
||||
const st = state.selectedState;
|
||||
const deptType = state.selectedType;
|
||||
const auditOnly = state.auditOnly;
|
||||
|
||||
let filtered = state.allPortals.filter(p => {
|
||||
// Search query filter
|
||||
if (q) {
|
||||
const matchName = (p.name || '').toLowerCase().includes(q);
|
||||
const matchLoc = (p.location || '').toLowerCase().includes(q);
|
||||
const matchSlug = (p.slug || '').toLowerCase().includes(q);
|
||||
const matchState = (p.state || '').toLowerCase().includes(q);
|
||||
const matchStateFull = getStateFullName(p.state).toLowerCase().includes(q);
|
||||
|
||||
if (!matchName && !matchLoc && !matchSlug && !matchState && !matchStateFull) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// State filter
|
||||
if (st !== 'ALL' && p.state !== st) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Department type filter
|
||||
if (deptType !== 'ALL') {
|
||||
const pType = (p.dept_type || '').toUpperCase();
|
||||
if (deptType === 'PD' && pType !== 'PD') return false;
|
||||
if (deptType === 'SO' && pType !== 'SO') return false;
|
||||
if (deptType === 'OTHER' && (pType === 'PD' || pType === 'SO')) return false;
|
||||
}
|
||||
|
||||
// Audit status filter
|
||||
if (auditOnly && !p.has_search_audit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sorting
|
||||
filtered.sort((a, b) => {
|
||||
switch (state.sortBy) {
|
||||
case 'cameras-desc':
|
||||
return (b.cameras || 0) - (a.cameras || 0);
|
||||
case 'vehicles-desc':
|
||||
return (b.vehicles_captured || 0) - (a.vehicles_captured || 0);
|
||||
case 'searches-desc':
|
||||
return (b.searches || 0) - (a.searches || 0);
|
||||
case 'hits-desc':
|
||||
return (b.hotlist_hits || 0) - (a.hotlist_hits || 0);
|
||||
case 'alpha-asc':
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
case 'alpha-desc':
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
state.filteredPortals = filtered;
|
||||
state.currentPage = 1;
|
||||
|
||||
renderDirectory();
|
||||
}
|
||||
|
||||
// --- RENDER MAIN DIRECTORY ---
|
||||
function renderDirectory() {
|
||||
const total = state.filteredPortals.length;
|
||||
el.totalCount.textContent = state.allPortals.length.toLocaleString();
|
||||
el.showingCount.textContent = total.toLocaleString();
|
||||
|
||||
// Pagination slice
|
||||
const startIndex = (state.currentPage - 1) * state.pageSize;
|
||||
const endIndex = Math.min(startIndex + state.pageSize, total);
|
||||
const pagePortals = state.filteredPortals.slice(startIndex, endIndex);
|
||||
|
||||
if (total === 0) {
|
||||
const emptyHtml = `
|
||||
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; background: var(--bg-surface); border: 1px dashed var(--border-subtle); border-radius: var(--radius-lg);">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" style="margin-bottom: 16px;">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 8px; color: #ffffff;">No Matching Portals Found</h3>
|
||||
<p style="color: var(--text-muted); max-width: 450px; margin: 0 auto 20px;">
|
||||
No Flock transparency portals matched your criteria: "${state.searchQuery || state.selectedState}".
|
||||
</p>
|
||||
<button class="btn-secondary" id="btn-empty-reset">Clear Filters & View All</button>
|
||||
</div>
|
||||
`;
|
||||
el.portalsGrid.innerHTML = emptyHtml;
|
||||
el.tableBody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 40px; color: var(--text-muted);">No portals found matching search criteria.</td></tr>`;
|
||||
el.paginationControls.innerHTML = '';
|
||||
|
||||
document.getElementById('btn-empty-reset')?.addEventListener('click', resetAllFilters);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentView === 'cards') {
|
||||
renderCards(pagePortals);
|
||||
} else {
|
||||
renderTable(pagePortals);
|
||||
}
|
||||
|
||||
renderPagination(total);
|
||||
}
|
||||
|
||||
// --- RENDER CARDS VIEW ---
|
||||
function renderCards(portals) {
|
||||
const cardsHtml = portals.map(p => {
|
||||
const camerasStr = p.cameras ? p.cameras.toLocaleString() : '—';
|
||||
const vehiclesStr = p.vehicles_captured ? formatNumberCompact(p.vehicles_captured) : '—';
|
||||
const searchesStr = p.searches ? p.searches.toLocaleString() : '—';
|
||||
const hitsStr = p.hotlist_hits ? p.hotlist_hits.toLocaleString() : '—';
|
||||
const sharedCount = p.shared_count || 0;
|
||||
|
||||
return `
|
||||
<article class="portal-card" data-slug="${escapeHtml(p.slug)}">
|
||||
<div>
|
||||
<div class="card-top-bar">
|
||||
<span class="agency-state-badge">${escapeHtml(p.state)}</span>
|
||||
<span class="agency-type-tag">${escapeHtml(p.dept_type || 'PD')}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-title-area">
|
||||
<h3 class="agency-name">${escapeHtml(p.name)}</h3>
|
||||
<div class="agency-location">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<span>${escapeHtml(p.location || p.name)}, ${escapeHtml(p.state)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<div class="card-metrics-grid">
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${camerasStr}</span>
|
||||
<span class="card-metric-lbl">Cameras</span>
|
||||
</div>
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${vehiclesStr}</span>
|
||||
<span class="card-metric-lbl">30d Captures</span>
|
||||
</div>
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${searchesStr}</span>
|
||||
<span class="card-metric-lbl">Searches</span>
|
||||
</div>
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${hitsStr}</span>
|
||||
<span class="card-metric-lbl">Hotlist Hits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flags -->
|
||||
<div class="card-flags-row">
|
||||
${p.has_search_audit ? `
|
||||
<span class="flag-badge audit-true" title="Agency publishes public search audit logs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
Search Audit Public
|
||||
</span>
|
||||
` : ''}
|
||||
|
||||
${sharedCount > 0 ? `
|
||||
<span class="flag-badge sharing-network" title="Shares camera data with outside agencies">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
Shares with ${sharedCount.toLocaleString()} Agenc${sharedCount === 1 ? 'y' : 'ies'}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Actions -->
|
||||
<div class="card-actions-row">
|
||||
<button class="btn-inspect" data-action="inspect" data-slug="${escapeHtml(p.slug)}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<span>Inspect Data</span>
|
||||
</button>
|
||||
<a
|
||||
href="${escapeHtml(p.url)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-direct-link"
|
||||
title="Open live portal on transparency.flocksafety.com"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
el.portalsGrid.innerHTML = cardsHtml;
|
||||
}
|
||||
|
||||
// --- RENDER TABLE VIEW ---
|
||||
function renderTable(portals) {
|
||||
const tableHtml = portals.map(p => {
|
||||
const camerasStr = p.cameras ? p.cameras.toLocaleString() : '—';
|
||||
const vehiclesStr = p.vehicles_captured ? p.vehicles_captured.toLocaleString() : '—';
|
||||
const searchesStr = p.searches ? p.searches.toLocaleString() : '—';
|
||||
const hitsStr = p.hotlist_hits ? p.hotlist_hits.toLocaleString() : '—';
|
||||
const sharedCount = p.shared_count || 0;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-agency-cell">
|
||||
<span class="table-agency-title">${escapeHtml(p.name)}</span>
|
||||
<span class="table-agency-slug">${escapeHtml(p.slug)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="agency-state-badge">${escapeHtml(p.state)}</span></td>
|
||||
<td style="font-family: var(--font-mono); font-weight: 700;">${camerasStr}</td>
|
||||
<td style="font-family: var(--font-mono);">${vehiclesStr}</td>
|
||||
<td style="font-family: var(--font-mono);">${searchesStr}</td>
|
||||
<td style="font-family: var(--font-mono); color: var(--accent-radar);">${hitsStr}</td>
|
||||
<td>
|
||||
<span style="font-size: 0.78rem; color: #a5b4fc;">${sharedCount} orgs</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-secondary" style="padding: 4px 10px; font-size: 0.75rem;" data-action="inspect" data-slug="${escapeHtml(p.slug)}">Inspect</button>
|
||||
<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener noreferrer" class="btn-direct-link" style="padding: 4px 8px;" title="Open Portal">↗</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
el.tableBody.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
// --- PAGINATION RENDERER ---
|
||||
function renderPagination(total) {
|
||||
const totalPages = Math.ceil(total / state.pageSize);
|
||||
if (totalPages <= 1) {
|
||||
el.paginationControls.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let pagesHtml = '';
|
||||
|
||||
// Previous Button
|
||||
pagesHtml += `
|
||||
<button class="pagination-btn" id="pg-prev" ${state.currentPage === 1 ? 'disabled' : ''}>
|
||||
← Prev
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Windowed Page Numbers
|
||||
const current = state.currentPage;
|
||||
let startPage = Math.max(1, current - 2);
|
||||
let endPage = Math.min(totalPages, current + 2);
|
||||
|
||||
if (startPage > 1) {
|
||||
pagesHtml += `<button class="pagination-btn" data-page="1">1</button>`;
|
||||
if (startPage > 2) pagesHtml += `<span style="color: var(--text-dim); padding: 0 4px;">...</span>`;
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pagesHtml += `
|
||||
<button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">
|
||||
${i}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) pagesHtml += `<span style="color: var(--text-dim); padding: 0 4px;">...</span>`;
|
||||
pagesHtml += `<button class="pagination-btn" data-page="${totalPages}">${totalPages}</button>`;
|
||||
}
|
||||
|
||||
// Next Button
|
||||
pagesHtml += `
|
||||
<button class="pagination-btn" id="pg-next" ${state.currentPage === totalPages ? 'disabled' : ''}>
|
||||
Next →
|
||||
</button>
|
||||
`;
|
||||
|
||||
el.paginationControls.innerHTML = pagesHtml;
|
||||
}
|
||||
|
||||
// --- DEEP AGENCY MODAL HANDLER ---
|
||||
function openAgencyModal(slug) {
|
||||
const portal = state.allPortals.find(p => p.slug === slug);
|
||||
if (!portal) {
|
||||
showToast('Agency record not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeModalPortal = portal;
|
||||
|
||||
// Fill Basic Details
|
||||
el.modalAgencyName.textContent = portal.name;
|
||||
el.modalAgencyLocation.textContent = `${portal.location || portal.name}, ${portal.state}`;
|
||||
el.modalAgencySlug.textContent = `transparency.flocksafety.com/${portal.slug}`;
|
||||
el.modalPortalUrl.href = portal.url;
|
||||
|
||||
// Stats
|
||||
el.modalStatCameras.textContent = portal.cameras ? portal.cameras.toLocaleString() : 'Not Reported';
|
||||
el.modalStatVehicles.textContent = portal.vehicles_captured ? portal.vehicles_captured.toLocaleString() : 'Not Reported';
|
||||
el.modalStatSearches.textContent = portal.searches ? portal.searches.toLocaleString() : '0';
|
||||
el.modalStatHits.textContent = portal.hotlist_hits ? portal.hotlist_hits.toLocaleString() : '0';
|
||||
|
||||
// Search Justifications / Reasons
|
||||
const reasons = portal.top_search_reasons || {};
|
||||
const reasonEntries = Object.entries(reasons).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (reasonEntries.length > 0) {
|
||||
el.modalReasonsSection.style.display = 'block';
|
||||
el.modalReasonsList.innerHTML = reasonEntries.map(([reason, count]) => `
|
||||
<div class="reason-item">
|
||||
<span class="reason-name">${escapeHtml(reason)}</span>
|
||||
<span class="reason-count">${count.toLocaleString()} searches</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
el.modalReasonsSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Inter-Agency Sharing Network
|
||||
const sharedIds = portal.shared_org_ids || [];
|
||||
el.modalSharingCount.textContent = sharedIds.length.toLocaleString();
|
||||
renderModalSharingTags(sharedIds, '');
|
||||
|
||||
// Reset sharing search input
|
||||
el.modalSharingSearch.value = '';
|
||||
|
||||
// Prohibited Uses Text
|
||||
el.modalProhibitedText.textContent = portal.prohibited_uses ||
|
||||
'Prohibited uses standardly include: Immigration enforcement, general traffic speed citations, harassment, or personal/non-official inquiries.';
|
||||
|
||||
// Research Tool Links
|
||||
const googleQuery = encodeURIComponent(`"${portal.name}" "Flock Safety" contract OR council OR resolution`);
|
||||
el.modalGoogleContractLink.href = `https://www.google.com/search?q=${googleQuery}`;
|
||||
|
||||
// Set URL Hash
|
||||
window.location.hash = portal.slug;
|
||||
|
||||
// Open Native Dialog
|
||||
if (typeof el.agencyModal.showModal === 'function') {
|
||||
el.agencyModal.showModal();
|
||||
} else {
|
||||
el.agencyModal.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalSharingTags(sharedIds, query) {
|
||||
if (!sharedIds || sharedIds.length === 0) {
|
||||
el.modalSharingBox.innerHTML = `<span style="font-size: 0.8rem; color: var(--text-dim); padding: 8px;">No external sharing partners published on portal.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
let orgNames = sharedIds.map(id => state.orgsLookup[id] || 'Unknown Agency');
|
||||
|
||||
if (q) {
|
||||
orgNames = orgNames.filter(name => name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
if (orgNames.length === 0) {
|
||||
el.modalSharingBox.innerHTML = `<span style="font-size: 0.8rem; color: var(--text-dim); padding: 8px;">No matching partner agencies.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show up to 200 partner agencies in the box
|
||||
const displayOrgs = orgNames.slice(0, 250);
|
||||
const tagsHtml = displayOrgs.map(name => `
|
||||
<span class="sharing-tag">${escapeHtml(name)}</span>
|
||||
`).join('');
|
||||
|
||||
const moreNotice = orgNames.length > 250
|
||||
? `<span style="font-size: 0.72rem; color: var(--text-dim); padding: 4px;">...and ${(orgNames.length - 250).toLocaleString()} more. Use search to filter.</span>`
|
||||
: '';
|
||||
|
||||
el.modalSharingBox.innerHTML = tagsHtml + moreNotice;
|
||||
}
|
||||
|
||||
function closeAgencyModal() {
|
||||
if (typeof el.agencyModal.close === 'function') {
|
||||
el.agencyModal.close();
|
||||
} else {
|
||||
el.agencyModal.removeAttribute('open');
|
||||
}
|
||||
|
||||
state.activeModalPortal = null;
|
||||
|
||||
// Clear hash without reloading
|
||||
history.replaceState(null, document.title, window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
// --- CSV EXPORT FUNCTIONALITY ---
|
||||
function exportFilteredToCsv() {
|
||||
const list = state.filteredPortals;
|
||||
if (!list || list.length === 0) {
|
||||
showToast('No portals to export.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Agency Name',
|
||||
'State',
|
||||
'Location',
|
||||
'Department Type',
|
||||
'Cameras Reported',
|
||||
'30-Day Vehicle Captures',
|
||||
'Searches Conducted',
|
||||
'Hotlist Alerts',
|
||||
'Public Search Audit',
|
||||
'Shared Agency Count',
|
||||
'Transparency Portal URL'
|
||||
];
|
||||
|
||||
const rows = list.map(p => [
|
||||
`"${(p.name || '').replace(/"/g, '""')}"`,
|
||||
`"${p.state || ''}"`,
|
||||
`"${(p.location || '').replace(/"/g, '""')}"`,
|
||||
`"${p.dept_type || ''}"`,
|
||||
p.cameras || 0,
|
||||
p.vehicles_captured || 0,
|
||||
p.searches || 0,
|
||||
p.hotlist_hits || 0,
|
||||
p.has_search_audit ? 'Yes' : 'No',
|
||||
p.shared_count || 0,
|
||||
`"${p.url || ''}"`
|
||||
]);
|
||||
|
||||
const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `flock_transparency_portals_${state.selectedState.toLowerCase()}_${timestamp}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast(`Exported ${list.length.toLocaleString()} portals to CSV.`, 'success');
|
||||
}
|
||||
|
||||
// --- TOAST NOTIFICATIONS ---
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
|
||||
let icon = 'ℹ️';
|
||||
if (type === 'success') icon = '✓';
|
||||
if (type === 'error') icon = '⚠️';
|
||||
if (type === 'warning') icon = '⚡';
|
||||
|
||||
toast.innerHTML = `<span>${icon}</span><span>${escapeHtml(message)}</span>`;
|
||||
el.toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateY(8px)';
|
||||
toast.style.transition = '0.25s ease';
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
// --- VIEW MODE TOGGLE ---
|
||||
function updateViewMode() {
|
||||
if (state.currentView === 'cards') {
|
||||
el.portalsGrid.style.display = 'grid';
|
||||
el.tableContainer.style.display = 'none';
|
||||
el.viewCardsBtn.classList.add('active');
|
||||
el.viewTableBtn.classList.remove('active');
|
||||
el.viewCardsBtn.setAttribute('aria-checked', 'true');
|
||||
el.viewTableBtn.setAttribute('aria-checked', 'false');
|
||||
} else {
|
||||
el.portalsGrid.style.display = 'none';
|
||||
el.tableContainer.style.display = 'block';
|
||||
el.viewCardsBtn.classList.remove('active');
|
||||
el.viewTableBtn.classList.add('active');
|
||||
el.viewCardsBtn.setAttribute('aria-checked', 'false');
|
||||
el.viewTableBtn.setAttribute('aria-checked', 'true');
|
||||
}
|
||||
localStorage.setItem('flock_dir_view', state.currentView);
|
||||
}
|
||||
|
||||
// --- RESET FILTERS ---
|
||||
function resetAllFilters() {
|
||||
state.searchQuery = '';
|
||||
state.selectedState = 'ALL';
|
||||
state.selectedType = 'ALL';
|
||||
state.auditOnly = false;
|
||||
state.sortBy = 'cameras-desc';
|
||||
|
||||
el.searchInput.value = '';
|
||||
el.clearSearchBtn.style.display = 'none';
|
||||
el.stateFilter.value = 'ALL';
|
||||
el.typeFilter.value = 'ALL';
|
||||
el.sortSelect.value = 'cameras-desc';
|
||||
el.auditToggle.checked = false;
|
||||
|
||||
renderStateChips();
|
||||
applyFilters();
|
||||
showToast('Filters reset to default view.');
|
||||
}
|
||||
|
||||
// --- URL HASH CHECK ---
|
||||
function checkUrlHash() {
|
||||
const hash = window.location.hash.replace('#', '').trim();
|
||||
if (hash) {
|
||||
const match = state.allPortals.find(p => p.slug === hash);
|
||||
if (match) {
|
||||
openAgencyModal(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- EVENT BINDINGS ---
|
||||
function bindEvents() {
|
||||
// Search input with debounce
|
||||
let debounceTimer;
|
||||
el.searchInput.addEventListener('input', e => {
|
||||
clearTimeout(debounceTimer);
|
||||
state.searchQuery = e.target.value;
|
||||
el.clearSearchBtn.style.display = state.searchQuery ? 'block' : 'none';
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
applyFilters();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Clear search button
|
||||
el.clearSearchBtn.addEventListener('click', () => {
|
||||
el.searchInput.value = '';
|
||||
state.searchQuery = '';
|
||||
el.clearSearchBtn.style.display = 'none';
|
||||
el.searchInput.focus();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Keyboard shortcut '/' to search
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.key === '/' && document.activeElement !== el.searchInput) {
|
||||
e.preventDefault();
|
||||
el.searchInput.focus();
|
||||
el.searchInput.select();
|
||||
}
|
||||
});
|
||||
|
||||
// State dropdown filter
|
||||
el.stateFilter.addEventListener('change', e => {
|
||||
state.selectedState = e.target.value;
|
||||
renderStateChips();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// State chips click
|
||||
el.stateChipsScroll.addEventListener('click', e => {
|
||||
const chip = e.target.closest('.state-chip');
|
||||
if (!chip) return;
|
||||
const st = chip.getAttribute('data-state');
|
||||
if (!st) return;
|
||||
|
||||
state.selectedState = st;
|
||||
el.stateFilter.value = st;
|
||||
renderStateChips();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Department type filter
|
||||
el.typeFilter.addEventListener('change', e => {
|
||||
state.selectedType = e.target.value;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Sort select
|
||||
el.sortSelect.addEventListener('change', e => {
|
||||
state.sortBy = e.target.value;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Public Audit toggle
|
||||
el.auditToggle.addEventListener('change', e => {
|
||||
state.auditOnly = e.target.checked;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Reset all filters button
|
||||
el.btnResetAll.addEventListener('click', resetAllFilters);
|
||||
|
||||
// View toggle buttons
|
||||
el.viewCardsBtn.addEventListener('click', () => {
|
||||
state.currentView = 'cards';
|
||||
updateViewMode();
|
||||
renderDirectory();
|
||||
});
|
||||
|
||||
el.viewTableBtn.addEventListener('click', () => {
|
||||
state.currentView = 'table';
|
||||
updateViewMode();
|
||||
renderDirectory();
|
||||
});
|
||||
|
||||
// Export CSV button
|
||||
el.btnExportCsv.addEventListener('click', exportFilteredToCsv);
|
||||
|
||||
// Card / Table click delegation for Inspect buttons
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-action="inspect"]');
|
||||
if (btn) {
|
||||
const slug = btn.getAttribute('data-slug');
|
||||
if (slug) openAgencyModal(slug);
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination button clicks
|
||||
el.paginationControls.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.pagination-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
if (btn.id === 'pg-prev') {
|
||||
state.currentPage = Math.max(1, state.currentPage - 1);
|
||||
} else if (btn.id === 'pg-next') {
|
||||
const maxPage = Math.ceil(state.filteredPortals.length / state.pageSize);
|
||||
state.currentPage = Math.min(maxPage, state.currentPage + 1);
|
||||
} else {
|
||||
const pageNum = parseInt(btn.getAttribute('data-page'), 10);
|
||||
if (pageNum) state.currentPage = pageNum;
|
||||
}
|
||||
|
||||
renderDirectory();
|
||||
// Smooth scroll back to top of directory
|
||||
document.getElementById('directory-section').scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Modal Close button
|
||||
el.modalCloseBtn.addEventListener('click', closeAgencyModal);
|
||||
|
||||
// Modal Click-Outside Backdrop Dismissal
|
||||
el.agencyModal.addEventListener('click', e => {
|
||||
const rect = el.agencyModal.getBoundingClientRect();
|
||||
const isInDialog = (
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width
|
||||
);
|
||||
if (!isInDialog) {
|
||||
closeAgencyModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal ESC Key
|
||||
el.agencyModal.addEventListener('cancel', e => {
|
||||
e.preventDefault();
|
||||
closeAgencyModal();
|
||||
});
|
||||
|
||||
// Modal Sharing Partners live search input
|
||||
el.modalSharingSearch.addEventListener('input', e => {
|
||||
if (!state.activeModalPortal) return;
|
||||
const sharedIds = state.activeModalPortal.shared_org_ids || [];
|
||||
renderModalSharingTags(sharedIds, e.target.value);
|
||||
});
|
||||
|
||||
// Modal Copy Link Button
|
||||
el.modalCopyLinkBtn.addEventListener('click', () => {
|
||||
if (!state.activeModalPortal) return;
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}#${state.activeModalPortal.slug}`;
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
showToast('Direct link copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
showToast(`Link: ${shareUrl}`, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
// Hash change event (forward/back browser navigation)
|
||||
window.addEventListener('hashchange', checkUrlHash);
|
||||
}
|
||||
|
||||
// --- UTILITY HELPERS ---
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatNumberCompact(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(0) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
// --- START APP ON DOM READY ---
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user