Files
FlockRadar/js/app.js
T

1193 lines
45 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'),
// Directory Specific Camera Nodes Elements
directoryCamerasSection: document.getElementById('directory-cameras-section'),
dirCamQueryTitle: document.getElementById('dir-cam-query-title'),
dirCamSubtitle: document.getElementById('dir-cam-subtitle'),
dirCamBadgeCount: document.getElementById('dir-cam-badge-count'),
directoryCamerasGrid: document.getElementById('directory-cameras-grid'),
// 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 || {};
// Register Local Sheriff Corridors so Directory search & filters match 100%
const localSheriffPortals = [
{
name: "Palmdale Station - Los Angeles County Sheriff (LASD)",
location: "Palmdale, CA",
state: "CA",
dept_type: "SO",
slug: "palmdale-ca-lasd",
cameras: 28,
vehicles_captured: 1850000,
searches: 14200,
hotlist_hits: 820,
has_search_audit: true,
url: "https://transparency.flocksafety.com/palmdale-ca-lasd",
shared_org_ids: [15, 73, 102, 145, 180, 204],
top_search_reasons: {
"10851 CVC Stolen Vehicle": 4120,
"Felony Warrant / BOLO": 2180,
"Amber Alert / Missing Person": 410,
"Robbery / Assault Investigation": 1890
},
prohibited_uses: "Prohibited from: Traffic speed ticketing, red light ticketing, facial recognition, or immigration enforcement."
},
{
name: "Lancaster Station - Los Angeles County Sheriff (LASD)",
location: "Lancaster, CA",
state: "CA",
dept_type: "SO",
slug: "lancaster-ca-lasd",
cameras: 32,
vehicles_captured: 2100000,
searches: 16800,
hotlist_hits: 950,
has_search_audit: true,
url: "https://transparency.flocksafety.com/lancaster-ca-lasd",
shared_org_ids: [15, 73, 102, 145, 180, 204],
top_search_reasons: {
"10851 CVC Stolen Vehicle": 4850,
"Felony Warrant / BOLO": 2400,
"Amber Alert / Missing Person": 520,
"Commercial Burglary Investigation": 2100
},
prohibited_uses: "Prohibited from: Traffic speed ticketing, red light ticketing, facial recognition, or immigration enforcement."
}
];
localSheriffPortals.forEach(lp => {
if (!state.allPortals.some(p => p.slug === lp.slug)) {
state.allPortals.unshift(lp);
}
});
// 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 SPECIFIC CAMERA NODES & HARDWARE IMAGES IN DIRECTORY ---
function renderDirectoryCameras(rawQuery, filteredPortals) {
if (!el.directoryCamerasSection || !el.directoryCamerasGrid) return;
const q = (rawQuery || '').trim().toLowerCase();
const allCams = (typeof window.getFlockAllCameras === 'function')
? window.getFlockAllCameras()
: [];
if (allCams.length === 0) {
el.directoryCamerasSection.style.display = 'none';
return;
}
let matchingCams = [];
if (q) {
matchingCams = allCams.filter(c => {
const nameMatch = (c.name || '').toLowerCase().includes(q);
const agencyMatch = (c.agency || '').toLowerCase().includes(q);
const slugMatch = (c.slug || '').toLowerCase().includes(q);
const notesMatch = (c.notes || '').toLowerCase().includes(q);
const cityMatch = (c.city || '').toLowerCase().includes(q);
const crossMatch = (c.crossStreets || '').toLowerCase().includes(q);
const stateMatch = (c.state || '').toLowerCase() === q;
return nameMatch || agencyMatch || slugMatch || notesMatch || cityMatch || crossMatch || stateMatch;
});
// Filter by state if state dropdown is not ALL
if (state.selectedState && state.selectedState !== 'ALL') {
matchingCams = matchingCams.filter(c => {
if (c.state) return c.state.toUpperCase() === state.selectedState.toUpperCase();
if (state.selectedState === 'CA') return c.id.includes('plm') || c.id.includes('lan') || c.id.includes('cal');
return true;
});
}
// Filter by agency type if not ALL
if (state.selectedType && state.selectedType !== 'ALL') {
matchingCams = matchingCams.filter(c => {
if (c.dept_type) return c.dept_type.toUpperCase() === state.selectedType.toUpperCase();
if (state.selectedType === 'SO') return (c.agency && (c.agency.toLowerCase().includes('sheriff') || c.agency.includes('LASD')));
if (state.selectedType === 'PD') return (c.agency && (c.agency.toLowerCase().includes('police') || c.agency.includes('PD')));
return true;
});
}
} else if (state.selectedState && state.selectedState !== 'ALL') {
matchingCams = allCams.filter(c => {
let stMatch = false;
if (c.state) stMatch = (c.state.toUpperCase() === state.selectedState.toUpperCase());
else if (state.selectedState === 'CA') stMatch = c.id.includes('plm') || c.id.includes('lan') || c.id.includes('cal');
else if (state.selectedState === 'KY') stMatch = c.id.includes('lex');
else if (state.selectedState === 'GA') stMatch = c.id.includes('atl');
else if (state.selectedState === 'TX') stMatch = c.id.includes('dfw');
if (!stMatch) return false;
if (state.selectedType && state.selectedType !== 'ALL') {
if (c.dept_type) return c.dept_type.toUpperCase() === state.selectedType.toUpperCase();
if (state.selectedType === 'SO') return (c.agency && (c.agency.toLowerCase().includes('sheriff') || c.agency.includes('LASD')));
if (state.selectedType === 'PD') return (c.agency && (c.agency.toLowerCase().includes('police') || c.agency.includes('PD')));
}
return true;
});
}
if (matchingCams.length === 0) {
el.directoryCamerasSection.style.display = 'none';
return;
}
// Show section
el.directoryCamerasSection.style.display = 'block';
// Set Header Title
let titleText = 'All Mapped Surveillance Corridors';
if (q) {
titleText = `"${escapeHtml(rawQuery)}"`;
} else if (state.selectedState !== 'ALL') {
titleText = `${state.selectedState} Corridor Locations`;
}
el.dirCamQueryTitle.innerHTML = titleText;
el.dirCamBadgeCount.textContent = `${matchingCams.length} Camera Nodes Mapped`;
// Render Camera Cards with Images, Cross-Streets, and Live Actions
const cardsHtml = matchingCams.map(cam => {
const meta = (window.FlockAuth && window.FlockAuth.getCameraMeta)
? window.FlockAuth.getCameraMeta(cam.id)
: { confirmations: 1, hasPhoto: false, isContested: false };
const photoUrl = meta.photoUrl || cam.photoUrl || null;
const hasPhoto = !!photoUrl;
const crossStreets = cam.crossStreets || (cam.name ? cam.name.split('(')[0].replace(/^.*?-\s*/, '').trim() : 'Intersection Camera');
const badgeHtml = meta.isContested
? `<span class="dir-cam-badge unverified">${window.FlockIcons ? window.FlockIcons.alertTriangle('flock-svg-sm') : ''} CONTESTED</span>`
: (hasPhoto
? `<span class="dir-cam-badge verified">${window.FlockIcons ? window.FlockIcons.checkCircle('flock-svg-sm') : ''} PHOTO VERIFIED</span>`
: `<span class="dir-cam-badge unverified">${window.FlockIcons ? window.FlockIcons.camera('flock-svg-sm') : ''} SIGHTING RECORDED</span>`);
const imageContainerHtml = hasPhoto
? `<div class="dir-cam-image-container">
<img src="${photoUrl}" class="dir-cam-img" alt="Camera at ${escapeHtml(crossStreets)}" onclick="window.open('${photoUrl}', '_blank')" title="Click to view full photo proof in high resolution">
${badgeHtml}
<div class="dir-cam-img-hover-hint">
${window.FlockIcons ? window.FlockIcons.search('flock-svg-sm') : ''}
<span>Tap to inspect photo proof</span>
</div>
</div>`
: `<div class="dir-cam-image-container pending">
<div class="dir-cam-placeholder">
<div class="dir-cam-placeholder-icon">
${window.FlockIcons ? window.FlockIcons.camera('flock-svg-lg') : ''}
</div>
<strong style="font-size: 0.84rem; color: #cbd5e1;">Hardware Photo Pending</strong>
<span style="font-size: 0.72rem; color: #94a3b8;">Mounted on utility / signal pole</span>
<button type="button" class="btn-dir-cam-upload-trigger" onclick="window.openCameraHardwareProof('${escapeHtml(cam.id)}')">
${window.FlockIcons ? window.FlockIcons.cameraPlus('flock-svg-sm') : ''}
<span>Upload Photo Proof</span>
</button>
</div>
${badgeHtml}
</div>`;
return `
<article class="dir-cam-card" id="dir-cam-node-${escapeHtml(cam.id)}">
${imageContainerHtml}
<div class="dir-cam-body">
<div>
<!-- Cross Streets Prominent Box -->
<div class="dir-cam-cross-box">
<span class="dir-cam-cross-icon">
${window.FlockIcons ? window.FlockIcons.mapPin('flock-svg-md') : ''}
</span>
<div class="dir-cam-cross-content">
<span class="dir-cam-cross-label">CROSS STREETS</span>
<strong class="dir-cam-cross-value">${escapeHtml(crossStreets)}</strong>
</div>
</div>
<h4 class="dir-cam-intersection">${escapeHtml(cam.name)}</h4>
<div class="dir-cam-agency-meta">
<span>${window.FlockIcons ? window.FlockIcons.building('flock-svg-sm') : ''} ${escapeHtml(cam.agency || 'Local Law Enforcement')}</span>
${cam.city ? `<span class="dir-cam-city-tag">${window.FlockIcons ? window.FlockIcons.map('flock-svg-sm') : ''} ${escapeHtml(cam.city)}, ${escapeHtml(cam.state || 'CA')}</span>` : ''}
</div>
<div class="dir-cam-specs-row">
<div class="dir-cam-spec-item">
<span class="dir-cam-spec-label">HARDWARE</span>
<span class="dir-cam-spec-val">${escapeHtml(cam.type || 'Falcon Solar ALPR')}</span>
</div>
<div class="dir-cam-spec-item">
<span class="dir-cam-spec-label">MONTHLY SCANS</span>
<span class="dir-cam-spec-val" style="color: #34d399;">${escapeHtml(cam.captures30d ? `${cam.captures30d}` : '300,000+')}</span>
</div>
<div class="dir-cam-spec-item">
<span class="dir-cam-spec-label">COORDINATES</span>
<span class="dir-cam-spec-val" style="font-family: var(--font-mono); font-size: 0.75rem;">${cam.lat.toFixed(4)}, ${cam.lng.toFixed(4)}</span>
</div>
<div class="dir-cam-spec-item">
<span class="dir-cam-spec-label">CONSENSUS</span>
<span class="dir-cam-spec-val" style="color: #38bdf8; display: inline-flex; align-items: center; gap: 4px;">
${window.FlockIcons ? window.FlockIcons.thumbUp('flock-svg-sm') : ''} ${meta.confirmations || 1} Confirmed
</span>
</div>
</div>
</div>
<div class="dir-cam-actions">
<button type="button" class="btn-dir-cam-jump" onclick="window.jumpToRadarCamera('${escapeHtml(cam.id)}')">
${window.FlockIcons ? window.FlockIcons.radar('flock-svg-sm') : ''}
<span>View on Live Radar</span>
</button>
<button type="button" class="btn-dir-cam-details" onclick="window.openCameraHardwareProof('${escapeHtml(cam.id)}')">
${window.FlockIcons ? window.FlockIcons.camera('flock-svg-sm') : ''}
<span>Camera Info</span>
</button>
</div>
</div>
</article>
`;
}).join('');
el.directoryCamerasGrid.innerHTML = cardsHtml;
}
// --- RENDER MAIN DIRECTORY ---
function renderDirectory() {
// Render specific camera nodes with photos and cross streets if matching
renderDirectoryCameras(state.searchQuery, state.filteredPortals);
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 iconSvg = window.FlockIcons ? FlockIcons.alertTriangle('flock-svg-sm') : '';
if (type === 'success') {
iconSvg = window.FlockIcons ? FlockIcons.checkCircle('flock-svg-sm') : '';
} else if (type === 'error') {
iconSvg = window.FlockIcons ? FlockIcons.alertTriangle('flock-svg-sm') : '';
} else if (type === 'warning') {
iconSvg = window.FlockIcons ? FlockIcons.zap('flock-svg-sm') : '';
} else {
iconSvg = window.FlockIcons ? FlockIcons.radar('flock-svg-sm') : '';
}
// Clean any leading emoji characters from message
const cleanMsg = String(message).replace(/^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\s]+/u, '').trim();
toast.innerHTML = `<span class="toast-icon">${iconSvg}</span><span>${escapeHtml(cleanMsg || message)}</span>`;
if (el.toastContainer) 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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();
}
})();