935 lines
33 KiB
JavaScript
935 lines
33 KiB
JavaScript
/**
|
||
* 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', '');
|
||
}
|
||
}
|
||
|
||
// Global access so radar map can open agency modal without switching views
|
||
window.openAgencyModal = openAgencyModal;
|
||
|
||
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();
|
||
}
|
||
|
||
})();
|