Files
FlockRadar/js/app.js
T

1126 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
return nameMatch || agencyMatch || slugMatch || notesMatch;
});
} else if (state.selectedState && state.selectedState !== 'ALL') {
matchingCams = allCams.filter(c => {
if (state.selectedState === 'CA') {
return c.id.includes('plm') || c.id.includes('lan') || c.id.includes('cal') || (c.agency && c.agency.includes('LASD'));
}
if (state.selectedState === 'KY') return c.id.includes('lex');
if (state.selectedState === 'GA') return c.id.includes('atl');
if (state.selectedState === 'TX') return c.id.includes('dfw');
return false;
});
}
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 badgeHtml = meta.isContested
? `<span class="dir-cam-badge unverified">🟠 CONTESTED</span>`
: (hasPhoto ? `<span class="dir-cam-badge verified">🟢 PHOTO VERIFIED</span>` : `<span class="dir-cam-badge unverified">⚠️ FLOCK RECORDED</span>`);
const imageContainerHtml = hasPhoto
? `<div class="dir-cam-image-container">
<img src="${photoUrl}" class="dir-cam-img" alt="Camera at ${escapeHtml(cam.name)}" onclick="window.open('${photoUrl}', '_blank')" title="Click to view full photo proof">
${badgeHtml}
</div>`
: `<div class="dir-cam-image-container">
<div class="dir-cam-placeholder">
<span class="dir-cam-placeholder-icon">📸</span>
<strong style="font-size: 0.82rem; color: #cbd5e1;">Hardware Photo Pending</strong>
<span style="font-size: 0.72rem; color: #94a3b8;">Mounted on utility / signal pole</span>
</div>
${badgeHtml}
</div>`;
return `
<article class="dir-cam-card" id="dir-cam-node-${escapeHtml(cam.id)}">
${imageContainerHtml}
<div class="dir-cam-body">
<div>
<h4 class="dir-cam-intersection">${escapeHtml(cam.name)}</h4>
<div class="dir-cam-agency-meta">
<span>🏛️ ${escapeHtml(cam.agency || 'Local Law Enforcement')}</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;">👍 ${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)}')">
<span>🗺️</span>
<span>View on Live Radar</span>
</button>
<button type="button" class="btn-dir-cam-details" onclick="window.openCameraHardwareProof('${escapeHtml(cam.id)}')">
<span>📸</span>
<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 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, '&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();
}
})();