feat: initial release of FlockRadar driving app and national transparency directory for flockradar.org
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
cert.pem
|
||||
key.pem
|
||||
.DS_Store
|
||||
node_modules/
|
||||
@@ -0,0 +1,32 @@
|
||||
# Flock Safety Transparency Portals Directory
|
||||
|
||||
A high-performance, public-interest web application that provides direct public access to over **1,439+ official Flock Safety Transparency Portals** across 45 U.S. states.
|
||||
|
||||
## Live Metrics Aggregated
|
||||
* **1,439+ Verified Transparency Portals**
|
||||
* **37,250+ Reported ALPR Cameras**
|
||||
* **346.7 Million+ Vehicles Tracked** (rolling 30-day window)
|
||||
* **256,900+ Law Enforcement Searches**
|
||||
* **10.2 Million+ Hotlist Hits**
|
||||
|
||||
## Key Capabilities
|
||||
1. **Instant Search & Filter:** Search by City, County, Agency Name, State, or URL slug.
|
||||
2. **State Quick Chips:** One-click filter by top states (CA, TX, GA, IL, OH, IN, NC, FL, MO, SC, WA, etc.).
|
||||
3. **Deep Agency Inspection Modal:**
|
||||
* Direct link to `https://transparency.flocksafety.com/[slug]`.
|
||||
* Camera numbers, 30-day captures, search counts, and hotlist alerts.
|
||||
* Stated search justifications (e.g., Stolen Vehicle, Burglary, Homicide, Missing Person).
|
||||
* Inter-agency sharing network (with partner agency search filter).
|
||||
* Prohibited uses policies.
|
||||
* One-click Google Search for local city council contracts and minutes.
|
||||
4. **CSV Export:** Download the filtered directory dataset with one click.
|
||||
5. **Civil Liberties Educational Section:** Covers Vehicle Fingerprinting, Fourth Amendment case law (*Carpenter v. United States*, *United States v. Jones*), and audit log search tools.
|
||||
|
||||
## Running Locally
|
||||
|
||||
From this directory:
|
||||
```bash
|
||||
# Start a simple local HTTP server
|
||||
python3 -m http.server 8080
|
||||
```
|
||||
Then open `http://localhost:8080` in your web browser.
|
||||
+2138
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+757
@@ -0,0 +1,757 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlockRadar | Live ALPR Camera Awareness & National Transparency Directory</title>
|
||||
<meta name="description" content="A Waze-style real-time ALPR radar navigation app alerting drivers to active Flock camera recorded zones with live proximity alerts, audio warnings, and a 1,439-portal public directory.">
|
||||
<meta name="keywords" content="Flock Safety, ALPR, camera radar, waze for flock, live radar, license plate readers, police surveillance, camera alerts">
|
||||
<meta name="author" content="Public ALPR Transparency Project">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="FlockRadar | Live ALPR Camera Awareness & Directory">
|
||||
<meta property="og:description" content="Drive with real-time awareness of Flock camera recorded zones with live proximity HUD alerts, audio warnings, and public portal data.">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;600;700&family=Outfit:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Leaflet Map CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||
|
||||
<!-- PWA & Mobile Web App Meta -->
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="FlockRadar">
|
||||
|
||||
<!-- Main Stylesheet -->
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ========================================================================
|
||||
SITE NAVIGATION & APP BAR
|
||||
======================================================================== -->
|
||||
<nav class="site-nav" aria-label="Main Navigation">
|
||||
<div class="container nav-content">
|
||||
<div class="nav-left">
|
||||
<a href="#" class="brand-wrapper" id="brand-link">
|
||||
<div class="radar-icon-box" aria-hidden="true">
|
||||
<div class="pulse-dot"></div>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="m12 12 5-5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-title">FLOCKRADAR</span>
|
||||
<span class="brand-tagline">ALPR Awareness Network</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- View Mode Switcher Pills -->
|
||||
<div class="nav-mode-switcher" role="tablist">
|
||||
<button id="nav-map-btn" class="nav-mode-btn active" role="tab" aria-selected="true">
|
||||
<span class="mode-icon">🗺️</span>
|
||||
<span>Live Radar Map</span>
|
||||
</button>
|
||||
<button id="nav-dir-btn" class="nav-mode-btn" role="tab" aria-selected="false">
|
||||
<span class="mode-icon">📋</span>
|
||||
<span>National Directory (1,439)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<div class="badge-live-pulse" title="Database synced with official public transparency portals">
|
||||
<span class="live-indicator-dot"></span>
|
||||
<span>RADAR ACTIVE</span>
|
||||
</div>
|
||||
|
||||
<!-- Driver Profile / Login Button -->
|
||||
<button id="driver-profile-btn" class="user-profile-pill" title="Driver Profile & Settings">
|
||||
<span id="user-profile-pill">
|
||||
<span class="avatar-dot">👤</span>
|
||||
<span>Driver Login</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ========================================================================
|
||||
VIEW 1: LIVE RADAR MAP & NAVIGATION INTERFACE (WAZE-STYLE)
|
||||
======================================================================== -->
|
||||
<section id="radar-view" class="radar-view-container">
|
||||
<!-- Leaflet Map Canvas -->
|
||||
<div id="radar-map" class="radar-map-canvas" aria-label="Interactive Radar Map"></div>
|
||||
|
||||
<!-- Floating Driver HUD (Top-Left) -->
|
||||
<aside class="driver-hud" aria-live="polite">
|
||||
<!-- Status Badge -->
|
||||
<div id="hud-zone-badge" class="hud-badge badge-clear">
|
||||
<span class="badge-dot dot-clear"></span>
|
||||
<span>ALL CLEAR</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Row: Speed + Nearest Distance -->
|
||||
<div class="hud-metrics-row">
|
||||
<div class="hud-metric-box">
|
||||
<div class="hud-val-group">
|
||||
<span class="hud-metric-number" id="hud-speed-val">0</span>
|
||||
<span class="hud-metric-unit">MPH</span>
|
||||
</div>
|
||||
<span class="hud-metric-label">Vehicle Speed</span>
|
||||
</div>
|
||||
|
||||
<div class="hud-metric-box">
|
||||
<div class="hud-val-group">
|
||||
<span class="hud-metric-number" id="hud-dist-val">—</span>
|
||||
<span class="hud-metric-unit" id="hud-dist-sub">FT</span>
|
||||
</div>
|
||||
<span class="hud-metric-label">Nearest Camera</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graduated Radar Detector Signal Gauge (Escort / Valentine One Style) -->
|
||||
<div class="hud-radar-gauge-container">
|
||||
<div class="hud-radar-gauge-header">
|
||||
<span class="hud-gauge-title">SURVEILLANCE RADAR SIGNAL</span>
|
||||
<span id="hud-gauge-state" class="hud-gauge-status status-green">ALL CLEAR</span>
|
||||
</div>
|
||||
<div class="hud-signal-meter" id="hud-signal-meter">
|
||||
<div class="signal-bar bar-1 active-green" title="Signal Level 1"></div>
|
||||
<div class="signal-bar bar-2" title="Signal Level 2"></div>
|
||||
<div class="signal-bar bar-3" title="Signal Level 3"></div>
|
||||
<div class="signal-bar bar-4" title="Signal Level 4"></div>
|
||||
<div class="signal-bar bar-5" title="Signal Level 5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Agency / Street Monitoring -->
|
||||
<div class="hud-location-bar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-cyan)" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
<polyline points="11 12 12 12 12 16 13 16"></polyline>
|
||||
</svg>
|
||||
<span id="hud-cam-agency">Monitoring Roadway Grid</span>
|
||||
</div>
|
||||
|
||||
<!-- Alert Sensitivity Selector (City Mode vs Highway Mode) -->
|
||||
<div class="hud-sensitivity-row">
|
||||
<span class="sensitivity-label">RADAR RANGE:</span>
|
||||
<div class="sensitivity-toggle-group">
|
||||
<button id="btn-sens-city" class="sens-btn active" title="Alert within 1,000 ft (Optimal for city driving)">🏙️ City (1k ft)</button>
|
||||
<button id="btn-sens-hwy" class="sens-btn" title="Alert within 2,500 ft (Optimal for freeway/SR-14 speeds)">🛣️ Highway (2.5k ft)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- High-Priority Pulsing Warning Banner -->
|
||||
<div id="hud-banner-alert" class="hud-banner-alert">
|
||||
<span class="banner-pulse-icon">⚠️</span>
|
||||
<div>
|
||||
<strong>FLOCK CAMERA ACTIVE ZONE</strong>
|
||||
<p>Vehicle Profile & Plate Logging Active</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Auto-Spotted Status Badge -->
|
||||
<div id="hud-spotted-badge" class="hud-spotted-badge" title="Location auto-detected via network & GPS">
|
||||
<span>🛰️</span>
|
||||
<span id="hud-spotted-text">Spotting Antelope Valley Location...</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Antelope Valley Selector Bar -->
|
||||
<div class="hud-location-selector">
|
||||
<div class="location-quick-chips">
|
||||
<button id="btn-locate-me" class="loc-chip active" title="Re-detect your location via IP & GPS">
|
||||
<span>🎯</span> Auto-Detect (GPS)
|
||||
</button>
|
||||
<button id="btn-loc-palmdale" class="loc-chip" title="Jump to Palmdale Blvd & 10th St W">
|
||||
<span>📍</span> Palmdale (Center)
|
||||
</button>
|
||||
<button id="btn-loc-lancaster" class="loc-chip" title="Jump to Lancaster ALPR Grid (Ave L & 10th St W)">
|
||||
<span>📍</span> Lancaster (Center)
|
||||
</button>
|
||||
<button id="btn-loc-palmdale-east" class="loc-chip" title="Jump to East Palmdale (Ave R & 47th St E)">
|
||||
<span>📍</span> East AV (Ave R)
|
||||
</button>
|
||||
<button id="btn-loc-palmdale-south" class="loc-chip" title="Jump to South Palmdale (Ave S)">
|
||||
<span>📍</span> South AV (Ave S)
|
||||
</button>
|
||||
<button id="btn-loc-rancho" class="loc-chip" title="Jump to West AV / Rancho Vista Blvd & Mall">
|
||||
<span>📍</span> West AV (Mall)
|
||||
</button>
|
||||
</div>
|
||||
<form id="map-search-form" class="map-search-box">
|
||||
<input type="text" id="map-search-input" placeholder="🔍 Search Antelope Valley street or address..." autocomplete="off">
|
||||
<button type="submit" id="btn-search-go">Jump</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Floating Navigation & Drive Simulator Controls (Top-Right) -->
|
||||
<div class="map-top-controls">
|
||||
<button id="btn-follow-car" class="btn-hud-action active" title="Automatically keep map centered on your vehicle">
|
||||
<span>🎯</span>
|
||||
<span>Follow Car: ON</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-live-gps" class="btn-hud-action" title="Start continuous real-time GPS tracking on your device">
|
||||
<span>📡</span>
|
||||
<span>Live GPS Drive</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-map-layer" class="btn-hud-action" title="Toggle between Dark Canvas and Satellite View">
|
||||
<span>🛰️</span>
|
||||
<span>Satellite / Dark</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-fullscreen-mount" class="btn-hud-action" title="Toggle Fullscreen Dashboard Mount Mode">
|
||||
<span>⛶</span>
|
||||
<span>Dashboard Fullscreen</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-test-camera" class="btn-hud-action" title="Drop a test camera 400ft ahead of your current location to test proximity alerts">
|
||||
<span>⚡</span>
|
||||
<span>Place Camera 400ft Ahead</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-test-alert" class="btn-hud-action" title="Immediately test voice alert and radar siren">
|
||||
<span>🔊</span>
|
||||
<span>Test Alert Voice</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-sim-drive" class="btn-hud-action" title="Simulate driving through Palmdale camera corridors">
|
||||
<span>▶</span>
|
||||
<span>Simulate Palmdale Drive</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-reset-sim" class="btn-hud-action" title="Reset drive position">
|
||||
<span>↺</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-mute-sound" class="btn-hud-action" title="Toggle audio alert chime and voice">
|
||||
<span>🔔</span>
|
||||
<span>Sound: ON</span>
|
||||
</button>
|
||||
|
||||
<button id="btn-mobile-connect" class="btn-hud-action" title="Show mobile network link to open on phone in car">
|
||||
<span>📱</span>
|
||||
<span>Phone Link</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Floating Map Legend (Bottom-Left) -->
|
||||
<div class="map-legend">
|
||||
<div class="legend-header">
|
||||
<span class="legend-pulse-dot"></span>
|
||||
<span>RADAR ZONE LEGEND</span>
|
||||
</div>
|
||||
<div class="legend-content">
|
||||
<div class="legend-item">
|
||||
<span class="legend-sample-zone"></span>
|
||||
<span>Camera Detection Zone (280-450m)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-sample-pin"></span>
|
||||
<span>ALPR Camera Node</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-sample-car"></span>
|
||||
<span>Your Vehicle Heading</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Recenter Pill (Appears when map is dragged away from car) -->
|
||||
<button id="btn-recenter-car" class="btn-recenter-pill hidden" title="Re-center map on your vehicle">
|
||||
<span>🎯</span>
|
||||
<span>Recenter on Vehicle</span>
|
||||
</button>
|
||||
|
||||
<!-- Giant 1-Tap Floating Action Button: Log Flock Camera (Bottom-Right) -->
|
||||
<div class="driver-fab-container">
|
||||
<button id="btn-quick-log-cam" class="btn-driver-fab" title="1-Tap: Log a Flock camera at your exact current GPS location">
|
||||
<div class="fab-icon-glow">📸</div>
|
||||
<div class="fab-text-group">
|
||||
<span class="fab-main-title">+ LOG FLOCK CAMERA</span>
|
||||
<span class="fab-sub-title">1-Tap Pin at GPS Location</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================================
|
||||
VIEW 2: NATIONAL DIRECTORY (ORIGINAL PORTAL DATABASE)
|
||||
======================================================================== -->
|
||||
<div id="directory-view" style="display: none;">
|
||||
<!-- Hero & Aggregate KPI Metrics -->
|
||||
<header class="hero-section">
|
||||
<div class="container">
|
||||
<div class="hero-pill">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
<span>CIVIC SURVEILLANCE TRANSPARENCY</span>
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">
|
||||
The Public Directory of <span class="gradient-text">Flock Safety Transparency Portals</span>
|
||||
</h1>
|
||||
|
||||
<p class="hero-subtitle">
|
||||
Search 1,439+ official police transparency portals detailing camera counts, 30-day vehicle detection volumes, database queries, and inter-agency sharing networks.
|
||||
</p>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-portals">1,439</div>
|
||||
<div class="stat-label">Public Portals</div>
|
||||
<div class="stat-subtext">Across 45 U.S. States</div>
|
||||
</div>
|
||||
<div class="stat-card radar-card">
|
||||
<div class="stat-number" id="stat-cameras">37,250+</div>
|
||||
<div class="stat-label">Cameras Reported</div>
|
||||
<div class="stat-subtext">Active Roadway ALPR Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-vehicles">346.7M</div>
|
||||
<div class="stat-label">Vehicles Tracked</div>
|
||||
<div class="stat-subtext">Rolling 30-Day Detections</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-searches">256,957</div>
|
||||
<div class="stat-label">Police Searches</div>
|
||||
<div class="stat-subtext">Database Queries Executed</div>
|
||||
</div>
|
||||
<div class="stat-card radar-card">
|
||||
<div class="stat-number" id="stat-hits">10.2M+</div>
|
||||
<div class="stat-label">Hotlist Hits</div>
|
||||
<div class="stat-subtext">Alerts Pushed to Cruisers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Directory Content -->
|
||||
<main class="container" id="directory-section">
|
||||
<!-- State Quick Jump Bar -->
|
||||
<div class="state-chips-wrapper">
|
||||
<div class="state-chips-header">
|
||||
<span class="chips-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"></polygon>
|
||||
</svg>
|
||||
Quick Filter by Top States
|
||||
</span>
|
||||
<button class="btn-secondary" id="btn-reset-all" style="padding: 4px 10px; font-size: 0.75rem;">Reset All Filters</button>
|
||||
</div>
|
||||
<div class="state-chips-scroll" id="state-chips-scroll"></div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Controls Panel -->
|
||||
<div class="controls-panel">
|
||||
<div class="search-input-wrapper">
|
||||
<span class="search-icon" aria-hidden="true">
|
||||
<svg width="20" height="20" 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>
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
class="search-input"
|
||||
placeholder="Search by city, county, agency name, or state (e.g. Lexington, Cobb County, Fort Worth)..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="clear-search-btn" class="clear-search-btn" title="Clear search">✕</button>
|
||||
<span class="search-shortcut">/</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-controls-grid">
|
||||
<div class="control-group">
|
||||
<label for="state-filter" class="control-label">State</label>
|
||||
<select id="state-filter" class="select-custom">
|
||||
<option value="ALL">All States (45)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="type-filter" class="control-label">Agency Type</label>
|
||||
<select id="type-filter" class="select-custom">
|
||||
<option value="ALL">All Agency Types</option>
|
||||
<option value="PD">Police Department (PD)</option>
|
||||
<option value="SO">Sheriff's Office (SO)</option>
|
||||
<option value="OTHER">Campus / Airport / Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="sort-select" class="control-label">Sort Order</label>
|
||||
<select id="sort-select" class="select-custom">
|
||||
<option value="cameras-desc">Most Cameras Reported</option>
|
||||
<option value="vehicles-desc">Most Vehicles Tracked</option>
|
||||
<option value="searches-desc">Most Police Searches</option>
|
||||
<option value="hits-desc">Most Hotlist Alerts</option>
|
||||
<option value="alpha-asc">Agency Name (A to Z)</option>
|
||||
<option value="alpha-desc">Agency Name (Z to A)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="checkbox-toggle-label">
|
||||
<input type="checkbox" id="audit-toggle">
|
||||
<span class="toggle-switch"></span>
|
||||
<span>Public Audit Available Only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-controls-row">
|
||||
<div class="results-counter" id="results-counter">
|
||||
Showing <strong id="showing-count">0</strong> of <strong id="total-count">1,439</strong> portals
|
||||
</div>
|
||||
|
||||
<div class="view-actions-group">
|
||||
<button class="btn-secondary" id="btn-export-csv" title="Download filtered dataset as CSV">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Export CSV
|
||||
</button>
|
||||
|
||||
<div class="view-toggle-btns" role="radiogroup" aria-label="View Switcher">
|
||||
<button class="view-btn active" id="view-cards-btn" title="Card View" aria-checked="true" role="radio">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="view-btn" id="view-table-btn" title="Table View" aria-checked="false" role="radio">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="8" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="8" y1="18" x2="21" y2="18"></line>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards Grid View -->
|
||||
<div class="portals-grid" id="portals-grid" aria-live="polite"></div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div class="table-container" id="table-container">
|
||||
<table class="portals-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agency / Department</th>
|
||||
<th>State</th>
|
||||
<th>Cameras</th>
|
||||
<th>30d Vehicles</th>
|
||||
<th>Searches</th>
|
||||
<th>Hotlist Alerts</th>
|
||||
<th>Sharing</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination-wrapper" id="pagination-controls"></div>
|
||||
</main>
|
||||
|
||||
<!-- Educational & Civil Liberties Section -->
|
||||
<section class="education-section" id="education-section">
|
||||
<div class="container">
|
||||
<div class="education-header">
|
||||
<h2 class="education-title">What You Need to Know About Flock Surveillance</h2>
|
||||
<p class="education-desc">
|
||||
Automated License Plate Readers (ALPR) represent one of the fastest-growing mass surveillance networks in United States history. Understanding how the technology works is essential for civic transparency.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="education-grid">
|
||||
<article class="edu-card">
|
||||
<div class="edu-card-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
|
||||
<circle cx="12" cy="13" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="edu-card-title">Beyond Plates: "Vehicle Fingerprint"</h3>
|
||||
<p class="edu-card-text">
|
||||
Flock cameras do not merely log plate numbers. Their computer vision models extract a full visual profile on every passing car:
|
||||
</p>
|
||||
<ul class="edu-card-list">
|
||||
<li>Make, model, color, and trim styling</li>
|
||||
<li>Bumper stickers, window decals, and roof racks</li>
|
||||
<li>Dents, damage, aftermarket wheels, and missing hubcaps</li>
|
||||
<li>Temporary and dealer paper tags</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="edu-card">
|
||||
<div class="edu-card-icon" aria-hidden="true" style="background: rgba(239, 68, 68, 0.12); border-color: rgba(239, 68, 68, 0.3); color: var(--accent-radar);">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="edu-card-title">Fourth Amendment & Warrantless Tracking</h3>
|
||||
<p class="edu-card-text">
|
||||
Civil liberties advocates argue that interconnected ALPR grids violate constitutional protections against warrantless dragnet tracking:
|
||||
</p>
|
||||
<ul class="edu-card-list">
|
||||
<li><strong>Carpenter v. United States (2018):</strong> Individuals maintain a reasonable expectation of privacy in the whole of their physical movements.</li>
|
||||
<li><strong>United States v. Jones (2012):</strong> Prolonged automated surveillance exceeds traditional visual police monitoring.</li>
|
||||
<li><strong>Cross-County Sharing:</strong> Inter-agency networks allow out-of-town agencies to track movements across jurisdictions without warrants.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="edu-card">
|
||||
<div class="edu-card-icon" aria-hidden="true" style="background: rgba(99, 102, 241, 0.12); border-color: rgba(99, 102, 241, 0.3); color: #a5b4fc;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="edu-card-title">How Transparency Portals Work</h3>
|
||||
<p class="edu-card-text">
|
||||
Transparency Portals provide valuable aggregate metrics, but have critical limitations that the public should understand:
|
||||
</p>
|
||||
<ul class="edu-card-list">
|
||||
<li><strong>Portals Are Voluntary:</strong> Police departments are not required to publish them. Many agencies keep deployments confidential.</li>
|
||||
<li><strong>Aggregate Only:</strong> Portals show monthly sums, not individual license plate queries.</li>
|
||||
<li><strong>Private HOA Feeds:</strong> Cameras purchased by private homeowners associations and linked to police are often unlisted.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-content">
|
||||
<div class="footer-disclaimer">
|
||||
<strong>About This Directory:</strong> Independent public interest directory aggregating publicly accessible Flock Safety transparency portals. Data is sourced from municipal portals and civic surveillance watchdogs.
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://transparency.flocksafety.com" target="_blank" rel="noopener noreferrer" class="footer-link">Flock Transparency</a>
|
||||
<a href="https://eyesonflock.com" target="_blank" rel="noopener noreferrer" class="footer-link">Eyes On Flock</a>
|
||||
<a href="https://haveibeenflocked.com" target="_blank" rel="noopener noreferrer" class="footer-link">Audit Search</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- ========================================================================
|
||||
MODAL 1: DEEP AGENCY INSPECTION MODAL (<dialog>)
|
||||
======================================================================== -->
|
||||
<dialog id="agency-modal" class="agency-dialog" aria-labelledby="modal-agency-name">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header-titles">
|
||||
<h2 id="modal-agency-name" class="modal-agency-name">Agency Name</h2>
|
||||
<div class="modal-agency-meta">
|
||||
<span id="modal-agency-location">City, ST</span>
|
||||
<span>•</span>
|
||||
<span id="modal-agency-slug" style="font-family: var(--font-mono); color: var(--accent-cyan);">slug</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="modal-close-btn" class="close-modal-btn" aria-label="Close modal">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="modal-cta-box">
|
||||
<div class="modal-cta-text">
|
||||
<h4>Official Flock Transparency Portal</h4>
|
||||
<p>Access the live, unedited municipal transparency dashboard directly on Flock’s servers.</p>
|
||||
</div>
|
||||
<a href="#" id="modal-portal-url" target="_blank" rel="noopener noreferrer" class="btn-open-flock">
|
||||
<span>Open Official Portal</span>
|
||||
<svg width="15" height="15" 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>
|
||||
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat-box">
|
||||
<div class="modal-stat-num" id="modal-stat-cameras">0</div>
|
||||
<div class="modal-stat-label">Cameras Reported</div>
|
||||
</div>
|
||||
<div class="modal-stat-box">
|
||||
<div class="modal-stat-num" id="modal-stat-vehicles">0</div>
|
||||
<div class="modal-stat-label">30-Day Captures</div>
|
||||
</div>
|
||||
<div class="modal-stat-box">
|
||||
<div class="modal-stat-num" id="modal-stat-searches">0</div>
|
||||
<div class="modal-stat-label">Searches Conducted</div>
|
||||
</div>
|
||||
<div class="modal-stat-box">
|
||||
<div class="modal-stat-num" id="modal-stat-hits">0</div>
|
||||
<div class="modal-stat-label">Hotlist Alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section" id="modal-reasons-section">
|
||||
<h3 class="modal-section-title">Reported Search Justifications</h3>
|
||||
<div class="reasons-list" id="modal-reasons-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<h3 class="modal-section-title">
|
||||
Inter-Agency Sharing Network (<span id="modal-sharing-count">0</span> Departments)
|
||||
</h3>
|
||||
<p class="policy-text" style="margin-bottom: 12px; font-size: 0.8rem;">
|
||||
Outside law enforcement agencies that can search vehicles logged by this agency's cameras.
|
||||
</p>
|
||||
<input type="text" id="modal-sharing-search" class="sharing-search-input" placeholder="Filter partner agencies...">
|
||||
<div class="sharing-partners-box" id="modal-sharing-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<h3 class="modal-section-title">Department Policy & Prohibited Uses</h3>
|
||||
<p class="policy-text" id="modal-prohibited-text">Standard policy restrictions apply.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<h3 class="modal-section-title">Public Records & Civic Research</h3>
|
||||
<div class="research-tools-row">
|
||||
<a href="#" id="modal-google-contract-link" target="_blank" rel="noopener noreferrer" class="btn-research">
|
||||
Search City Council Contract & Minutes ↗
|
||||
</a>
|
||||
<button id="modal-copy-link-btn" class="btn-research">Copy Direct Portal Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- ========================================================================
|
||||
MODAL 2: WAZE-STYLE DRIVER LOGIN & PROFILE MODAL (<dialog>)
|
||||
======================================================================== -->
|
||||
<dialog id="login-modal" class="agency-dialog" aria-labelledby="login-modal-title">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header-titles">
|
||||
<h2 id="login-modal-title" class="modal-agency-name">Driver Profile & Scout Login</h2>
|
||||
<div class="modal-agency-meta">Join the community awareness grid to contribute camera sightings</div>
|
||||
</div>
|
||||
<button id="login-close-btn" class="close-modal-btn" aria-label="Close modal">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="login-form">
|
||||
<div class="form-group" style="margin-bottom: 16px;">
|
||||
<label class="control-label" for="login-username" style="display:block; margin-bottom: 6px;">Driver Handle / Callsign</label>
|
||||
<input type="text" id="login-username" class="search-input" style="padding: 12px;" placeholder="e.g. NightRider, HighwayScout" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 16px;">
|
||||
<label class="control-label" for="login-vehicle" style="display:block; margin-bottom: 6px;">Vehicle Description (For Scout Profile)</label>
|
||||
<input type="text" id="login-vehicle" class="search-input" style="padding: 12px;" placeholder="e.g. 2022 Honda Civic (Blue)" value="Sedan (Silver)">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 24px;">
|
||||
<label class="checkbox-toggle-label" style="margin-top: 0;">
|
||||
<input type="checkbox" id="login-sound" checked>
|
||||
<span class="toggle-switch"></span>
|
||||
<span>Enable Audio Chimes on Zone Entry</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button type="submit" class="btn-open-flock" style="flex: 1; justify-content: center; cursor: pointer; border: none;">
|
||||
Save Profile & Connect
|
||||
</button>
|
||||
<button type="button" id="login-guest-btn" class="btn-secondary" style="padding: 10px 18px;">
|
||||
Continue as Guest Scout
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- ========================================================================
|
||||
MODAL 3: CROWDSOURCED CAMERA REPORT MODAL (<dialog>)
|
||||
======================================================================== -->
|
||||
<dialog id="report-modal" class="agency-dialog" aria-labelledby="report-modal-title">
|
||||
<div class="modal-header">
|
||||
<div class="modal-header-titles">
|
||||
<h2 id="report-modal-title" class="modal-agency-name">Report Flock Camera Sighting</h2>
|
||||
<div class="modal-agency-meta">Help alert the driver network to new ALPR surveillance installations</div>
|
||||
</div>
|
||||
<button id="report-close-btn" class="close-modal-btn" aria-label="Close modal">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="report-form">
|
||||
<div class="form-group" style="margin-bottom: 16px;">
|
||||
<label class="control-label" for="report-mount-type" style="display:block; margin-bottom: 6px;">Camera Installation Type</label>
|
||||
<select id="report-mount-type" class="select-custom" style="width: 100%;">
|
||||
<option value="Solar Pole Mount">Solar Pole Mount (Standard Falcon)</option>
|
||||
<option value="Traffic Signal Mount">Traffic Signal / Utility Pole Mount</option>
|
||||
<option value="Raven Audio Node">Raven Audio Sensor + Camera Combo</option>
|
||||
<option value="Mobile Trailer ALPR">Mobile Surveillance Trailer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 16px;">
|
||||
<label class="control-label" for="report-intersection" style="display:block; margin-bottom: 6px;">Intersection / Highway Marker</label>
|
||||
<input type="text" id="report-intersection" class="search-input" style="padding: 12px;" placeholder="e.g. Main St & 4th Ave, Mile Marker 104" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 16px;">
|
||||
<label class="control-label" for="report-notes" style="display:block; margin-bottom: 6px;">Observations / Direction Facing</label>
|
||||
<input type="text" id="report-notes" class="search-input" style="padding: 12px;" placeholder="e.g. Northbound lanes, black solar panel on pole">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px;">
|
||||
<div>
|
||||
<label class="control-label" for="report-lat" style="display:block; margin-bottom: 6px;">Latitude</label>
|
||||
<input type="number" step="0.00001" id="report-lat" class="search-input" style="padding: 10px;" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="control-label" for="report-lng" style="display:block; margin-bottom: 6px;">Longitude</label>
|
||||
<input type="number" step="0.00001" id="report-lng" class="search-input" style="padding: 10px;" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-open-flock" style="width: 100%; justify-content: center; cursor: pointer; border: none;">
|
||||
Broadcast Camera Alert to Network (+50 pts)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container" class="toast-container" aria-live="assertive"></div>
|
||||
|
||||
<!-- Leaflet Map JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
|
||||
<!-- Data & Application Logic -->
|
||||
<script src="data/portals.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/radar.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* FLOCK SAFETY TRANSPARENCY PORTALS DIRECTORY
|
||||
* Client Application Logic
|
||||
* High-performance search, filtering, pagination, modal inspector, and CSV export.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// --- APPLICATION STATE ---
|
||||
const state = {
|
||||
allPortals: [],
|
||||
orgsLookup: [],
|
||||
summary: {},
|
||||
filteredPortals: [],
|
||||
currentPage: 1,
|
||||
pageSize: 24,
|
||||
currentView: 'cards', // 'cards' or 'table'
|
||||
searchQuery: '',
|
||||
selectedState: 'ALL',
|
||||
selectedType: 'ALL',
|
||||
auditOnly: false,
|
||||
sortBy: 'cameras-desc',
|
||||
activeModalPortal: null
|
||||
};
|
||||
|
||||
// --- DOM ELEMENTS ---
|
||||
const el = {
|
||||
// Search & Filters
|
||||
searchInput: document.getElementById('search-input'),
|
||||
clearSearchBtn: document.getElementById('clear-search-btn'),
|
||||
stateFilter: document.getElementById('state-filter'),
|
||||
typeFilter: document.getElementById('type-filter'),
|
||||
sortSelect: document.getElementById('sort-select'),
|
||||
auditToggle: document.getElementById('audit-toggle'),
|
||||
btnResetAll: document.getElementById('btn-reset-all'),
|
||||
stateChipsScroll: document.getElementById('state-chips-scroll'),
|
||||
|
||||
// Stats Counters
|
||||
statPortals: document.getElementById('stat-portals'),
|
||||
statCameras: document.getElementById('stat-cameras'),
|
||||
statVehicles: document.getElementById('stat-vehicles'),
|
||||
statSearches: document.getElementById('stat-searches'),
|
||||
statHits: document.getElementById('stat-hits'),
|
||||
|
||||
// Views & Results
|
||||
resultsCounter: document.getElementById('results-counter'),
|
||||
showingCount: document.getElementById('showing-count'),
|
||||
totalCount: document.getElementById('total-count'),
|
||||
portalsGrid: document.getElementById('portals-grid'),
|
||||
tableContainer: document.getElementById('table-container'),
|
||||
tableBody: document.getElementById('table-body'),
|
||||
paginationControls: document.getElementById('pagination-controls'),
|
||||
viewCardsBtn: document.getElementById('view-cards-btn'),
|
||||
viewTableBtn: document.getElementById('view-table-btn'),
|
||||
btnExportCsv: document.getElementById('btn-export-csv'),
|
||||
|
||||
// Modal Elements
|
||||
agencyModal: document.getElementById('agency-modal'),
|
||||
modalCloseBtn: document.getElementById('modal-close-btn'),
|
||||
modalAgencyName: document.getElementById('modal-agency-name'),
|
||||
modalAgencyLocation: document.getElementById('modal-agency-location'),
|
||||
modalAgencySlug: document.getElementById('modal-agency-slug'),
|
||||
modalPortalUrl: document.getElementById('modal-portal-url'),
|
||||
modalStatCameras: document.getElementById('modal-stat-cameras'),
|
||||
modalStatVehicles: document.getElementById('modal-stat-vehicles'),
|
||||
modalStatSearches: document.getElementById('modal-stat-searches'),
|
||||
modalStatHits: document.getElementById('modal-stat-hits'),
|
||||
modalReasonsSection: document.getElementById('modal-reasons-section'),
|
||||
modalReasonsList: document.getElementById('modal-reasons-list'),
|
||||
modalSharingCount: document.getElementById('modal-sharing-count'),
|
||||
modalSharingSearch: document.getElementById('modal-sharing-search'),
|
||||
modalSharingBox: document.getElementById('modal-sharing-box'),
|
||||
modalProhibitedText: document.getElementById('modal-prohibited-text'),
|
||||
modalGoogleContractLink: document.getElementById('modal-google-contract-link'),
|
||||
modalCopyLinkBtn: document.getElementById('modal-copy-link-btn'),
|
||||
|
||||
// Toast Container
|
||||
toastContainer: document.getElementById('toast-container')
|
||||
};
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
function init() {
|
||||
if (!window.FLOCK_DATA || !window.FLOCK_DATA.portals) {
|
||||
showToast('Error: Dataset failed to load.', 'error');
|
||||
console.error('FLOCK_DATA not found');
|
||||
return;
|
||||
}
|
||||
|
||||
state.allPortals = window.FLOCK_DATA.portals || [];
|
||||
state.orgsLookup = window.FLOCK_DATA.orgs || [];
|
||||
state.summary = window.FLOCK_DATA.summary || {};
|
||||
|
||||
// Restore saved view preference
|
||||
const savedView = localStorage.getItem('flock_dir_view');
|
||||
if (savedView === 'table' || savedView === 'cards') {
|
||||
state.currentView = savedView;
|
||||
}
|
||||
|
||||
renderHeroStats();
|
||||
populateStateFilter();
|
||||
renderStateChips();
|
||||
bindEvents();
|
||||
|
||||
// Initial Filter & Render
|
||||
applyFilters();
|
||||
updateViewMode();
|
||||
|
||||
// Check URL Hash for deep link
|
||||
checkUrlHash();
|
||||
}
|
||||
|
||||
// --- HERO STATS FORMATTING ---
|
||||
function renderHeroStats() {
|
||||
const s = state.summary;
|
||||
if (!s) return;
|
||||
|
||||
if (s.total_portals_found) {
|
||||
el.statPortals.textContent = s.total_portals_found.toLocaleString();
|
||||
}
|
||||
if (s.total_cameras) {
|
||||
el.statCameras.textContent = s.total_cameras.toLocaleString() + '+';
|
||||
}
|
||||
if (s.total_vehicles_captured) {
|
||||
const millions = (s.total_vehicles_captured / 1000000).toFixed(1);
|
||||
el.statVehicles.textContent = millions + 'M';
|
||||
}
|
||||
if (s.total_searches) {
|
||||
el.statSearches.textContent = s.total_searches.toLocaleString();
|
||||
}
|
||||
if (s.total_hotlist_hits) {
|
||||
const millions = (s.total_hotlist_hits / 1000000).toFixed(1);
|
||||
el.statHits.textContent = millions + 'M+';
|
||||
}
|
||||
}
|
||||
|
||||
// --- POPULATE STATE SELECTOR & CHIPS ---
|
||||
function populateStateFilter() {
|
||||
const stateCounts = {};
|
||||
state.allPortals.forEach(p => {
|
||||
const st = p.state || 'OTHER';
|
||||
stateCounts[st] = (stateCounts[st] || 0) + 1;
|
||||
});
|
||||
|
||||
const sortedStates = Object.keys(stateCounts).sort();
|
||||
|
||||
// Clear existing options except ALL
|
||||
el.stateFilter.innerHTML = `<option value="ALL">All States (${state.allPortals.length} portals)</option>`;
|
||||
|
||||
sortedStates.forEach(st => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = st;
|
||||
opt.textContent = `${getStateFullName(st)} (${st}) — ${stateCounts[st]} portals`;
|
||||
el.stateFilter.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStateChips() {
|
||||
const stateCounts = {};
|
||||
state.allPortals.forEach(p => {
|
||||
const st = p.state || 'OTHER';
|
||||
stateCounts[st] = (stateCounts[st] || 0) + 1;
|
||||
});
|
||||
|
||||
// Top states by portal count
|
||||
const topStates = Object.entries(stateCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 16);
|
||||
|
||||
let chipsHtml = `
|
||||
<button class="state-chip ${state.selectedState === 'ALL' ? 'active' : ''}" data-state="ALL">
|
||||
<span>All States</span>
|
||||
<span class="chip-count">(${state.allPortals.length})</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
topStates.forEach(([st, count]) => {
|
||||
const isActive = state.selectedState === st;
|
||||
chipsHtml += `
|
||||
<button class="state-chip ${isActive ? 'active' : ''}" data-state="${st}">
|
||||
<span>${st}</span>
|
||||
<span class="chip-count">(${count})</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
el.stateChipsScroll.innerHTML = chipsHtml;
|
||||
}
|
||||
|
||||
// --- STATE FULL NAME HELPER ---
|
||||
const stateNames = {
|
||||
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
||||
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
|
||||
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
||||
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
|
||||
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri',
|
||||
MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey',
|
||||
NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio',
|
||||
OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina',
|
||||
SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont',
|
||||
VA: 'Virginia', WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming'
|
||||
};
|
||||
|
||||
function getStateFullName(abbr) {
|
||||
return stateNames[abbr] || abbr;
|
||||
}
|
||||
|
||||
// --- FILTER & SORT LOGIC ---
|
||||
function applyFilters() {
|
||||
const q = state.searchQuery.trim().toLowerCase();
|
||||
const st = state.selectedState;
|
||||
const deptType = state.selectedType;
|
||||
const auditOnly = state.auditOnly;
|
||||
|
||||
let filtered = state.allPortals.filter(p => {
|
||||
// Search query filter
|
||||
if (q) {
|
||||
const matchName = (p.name || '').toLowerCase().includes(q);
|
||||
const matchLoc = (p.location || '').toLowerCase().includes(q);
|
||||
const matchSlug = (p.slug || '').toLowerCase().includes(q);
|
||||
const matchState = (p.state || '').toLowerCase().includes(q);
|
||||
const matchStateFull = getStateFullName(p.state).toLowerCase().includes(q);
|
||||
|
||||
if (!matchName && !matchLoc && !matchSlug && !matchState && !matchStateFull) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// State filter
|
||||
if (st !== 'ALL' && p.state !== st) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Department type filter
|
||||
if (deptType !== 'ALL') {
|
||||
const pType = (p.dept_type || '').toUpperCase();
|
||||
if (deptType === 'PD' && pType !== 'PD') return false;
|
||||
if (deptType === 'SO' && pType !== 'SO') return false;
|
||||
if (deptType === 'OTHER' && (pType === 'PD' || pType === 'SO')) return false;
|
||||
}
|
||||
|
||||
// Audit status filter
|
||||
if (auditOnly && !p.has_search_audit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sorting
|
||||
filtered.sort((a, b) => {
|
||||
switch (state.sortBy) {
|
||||
case 'cameras-desc':
|
||||
return (b.cameras || 0) - (a.cameras || 0);
|
||||
case 'vehicles-desc':
|
||||
return (b.vehicles_captured || 0) - (a.vehicles_captured || 0);
|
||||
case 'searches-desc':
|
||||
return (b.searches || 0) - (a.searches || 0);
|
||||
case 'hits-desc':
|
||||
return (b.hotlist_hits || 0) - (a.hotlist_hits || 0);
|
||||
case 'alpha-asc':
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
case 'alpha-desc':
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
state.filteredPortals = filtered;
|
||||
state.currentPage = 1;
|
||||
|
||||
renderDirectory();
|
||||
}
|
||||
|
||||
// --- RENDER MAIN DIRECTORY ---
|
||||
function renderDirectory() {
|
||||
const total = state.filteredPortals.length;
|
||||
el.totalCount.textContent = state.allPortals.length.toLocaleString();
|
||||
el.showingCount.textContent = total.toLocaleString();
|
||||
|
||||
// Pagination slice
|
||||
const startIndex = (state.currentPage - 1) * state.pageSize;
|
||||
const endIndex = Math.min(startIndex + state.pageSize, total);
|
||||
const pagePortals = state.filteredPortals.slice(startIndex, endIndex);
|
||||
|
||||
if (total === 0) {
|
||||
const emptyHtml = `
|
||||
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; background: var(--bg-surface); border: 1px dashed var(--border-subtle); border-radius: var(--radius-lg);">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" style="margin-bottom: 16px;">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 8px; color: #ffffff;">No Matching Portals Found</h3>
|
||||
<p style="color: var(--text-muted); max-width: 450px; margin: 0 auto 20px;">
|
||||
No Flock transparency portals matched your criteria: "${state.searchQuery || state.selectedState}".
|
||||
</p>
|
||||
<button class="btn-secondary" id="btn-empty-reset">Clear Filters & View All</button>
|
||||
</div>
|
||||
`;
|
||||
el.portalsGrid.innerHTML = emptyHtml;
|
||||
el.tableBody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 40px; color: var(--text-muted);">No portals found matching search criteria.</td></tr>`;
|
||||
el.paginationControls.innerHTML = '';
|
||||
|
||||
document.getElementById('btn-empty-reset')?.addEventListener('click', resetAllFilters);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentView === 'cards') {
|
||||
renderCards(pagePortals);
|
||||
} else {
|
||||
renderTable(pagePortals);
|
||||
}
|
||||
|
||||
renderPagination(total);
|
||||
}
|
||||
|
||||
// --- RENDER CARDS VIEW ---
|
||||
function renderCards(portals) {
|
||||
const cardsHtml = portals.map(p => {
|
||||
const camerasStr = p.cameras ? p.cameras.toLocaleString() : '—';
|
||||
const vehiclesStr = p.vehicles_captured ? formatNumberCompact(p.vehicles_captured) : '—';
|
||||
const searchesStr = p.searches ? p.searches.toLocaleString() : '—';
|
||||
const hitsStr = p.hotlist_hits ? p.hotlist_hits.toLocaleString() : '—';
|
||||
const sharedCount = p.shared_count || 0;
|
||||
|
||||
return `
|
||||
<article class="portal-card" data-slug="${escapeHtml(p.slug)}">
|
||||
<div>
|
||||
<div class="card-top-bar">
|
||||
<span class="agency-state-badge">${escapeHtml(p.state)}</span>
|
||||
<span class="agency-type-tag">${escapeHtml(p.dept_type || 'PD')}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-title-area">
|
||||
<h3 class="agency-name">${escapeHtml(p.name)}</h3>
|
||||
<div class="agency-location">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<span>${escapeHtml(p.location || p.name)}, ${escapeHtml(p.state)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<div class="card-metrics-grid">
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${camerasStr}</span>
|
||||
<span class="card-metric-lbl">Cameras</span>
|
||||
</div>
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${vehiclesStr}</span>
|
||||
<span class="card-metric-lbl">30d Captures</span>
|
||||
</div>
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${searchesStr}</span>
|
||||
<span class="card-metric-lbl">Searches</span>
|
||||
</div>
|
||||
<div class="card-metric-item">
|
||||
<span class="card-metric-val">${hitsStr}</span>
|
||||
<span class="card-metric-lbl">Hotlist Hits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flags -->
|
||||
<div class="card-flags-row">
|
||||
${p.has_search_audit ? `
|
||||
<span class="flag-badge audit-true" title="Agency publishes public search audit logs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
Search Audit Public
|
||||
</span>
|
||||
` : ''}
|
||||
|
||||
${sharedCount > 0 ? `
|
||||
<span class="flag-badge sharing-network" title="Shares camera data with outside agencies">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
Shares with ${sharedCount.toLocaleString()} Agenc${sharedCount === 1 ? 'y' : 'ies'}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Actions -->
|
||||
<div class="card-actions-row">
|
||||
<button class="btn-inspect" data-action="inspect" data-slug="${escapeHtml(p.slug)}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<span>Inspect Data</span>
|
||||
</button>
|
||||
<a
|
||||
href="${escapeHtml(p.url)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-direct-link"
|
||||
title="Open live portal on transparency.flocksafety.com"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
el.portalsGrid.innerHTML = cardsHtml;
|
||||
}
|
||||
|
||||
// --- RENDER TABLE VIEW ---
|
||||
function renderTable(portals) {
|
||||
const tableHtml = portals.map(p => {
|
||||
const camerasStr = p.cameras ? p.cameras.toLocaleString() : '—';
|
||||
const vehiclesStr = p.vehicles_captured ? p.vehicles_captured.toLocaleString() : '—';
|
||||
const searchesStr = p.searches ? p.searches.toLocaleString() : '—';
|
||||
const hitsStr = p.hotlist_hits ? p.hotlist_hits.toLocaleString() : '—';
|
||||
const sharedCount = p.shared_count || 0;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-agency-cell">
|
||||
<span class="table-agency-title">${escapeHtml(p.name)}</span>
|
||||
<span class="table-agency-slug">${escapeHtml(p.slug)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="agency-state-badge">${escapeHtml(p.state)}</span></td>
|
||||
<td style="font-family: var(--font-mono); font-weight: 700;">${camerasStr}</td>
|
||||
<td style="font-family: var(--font-mono);">${vehiclesStr}</td>
|
||||
<td style="font-family: var(--font-mono);">${searchesStr}</td>
|
||||
<td style="font-family: var(--font-mono); color: var(--accent-radar);">${hitsStr}</td>
|
||||
<td>
|
||||
<span style="font-size: 0.78rem; color: #a5b4fc;">${sharedCount} orgs</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn-secondary" style="padding: 4px 10px; font-size: 0.75rem;" data-action="inspect" data-slug="${escapeHtml(p.slug)}">Inspect</button>
|
||||
<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener noreferrer" class="btn-direct-link" style="padding: 4px 8px;" title="Open Portal">↗</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
el.tableBody.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
// --- PAGINATION RENDERER ---
|
||||
function renderPagination(total) {
|
||||
const totalPages = Math.ceil(total / state.pageSize);
|
||||
if (totalPages <= 1) {
|
||||
el.paginationControls.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let pagesHtml = '';
|
||||
|
||||
// Previous Button
|
||||
pagesHtml += `
|
||||
<button class="pagination-btn" id="pg-prev" ${state.currentPage === 1 ? 'disabled' : ''}>
|
||||
← Prev
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Windowed Page Numbers
|
||||
const current = state.currentPage;
|
||||
let startPage = Math.max(1, current - 2);
|
||||
let endPage = Math.min(totalPages, current + 2);
|
||||
|
||||
if (startPage > 1) {
|
||||
pagesHtml += `<button class="pagination-btn" data-page="1">1</button>`;
|
||||
if (startPage > 2) pagesHtml += `<span style="color: var(--text-dim); padding: 0 4px;">...</span>`;
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pagesHtml += `
|
||||
<button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">
|
||||
${i}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) pagesHtml += `<span style="color: var(--text-dim); padding: 0 4px;">...</span>`;
|
||||
pagesHtml += `<button class="pagination-btn" data-page="${totalPages}">${totalPages}</button>`;
|
||||
}
|
||||
|
||||
// Next Button
|
||||
pagesHtml += `
|
||||
<button class="pagination-btn" id="pg-next" ${state.currentPage === totalPages ? 'disabled' : ''}>
|
||||
Next →
|
||||
</button>
|
||||
`;
|
||||
|
||||
el.paginationControls.innerHTML = pagesHtml;
|
||||
}
|
||||
|
||||
// --- DEEP AGENCY MODAL HANDLER ---
|
||||
function openAgencyModal(slug) {
|
||||
const portal = state.allPortals.find(p => p.slug === slug);
|
||||
if (!portal) {
|
||||
showToast('Agency record not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeModalPortal = portal;
|
||||
|
||||
// Fill Basic Details
|
||||
el.modalAgencyName.textContent = portal.name;
|
||||
el.modalAgencyLocation.textContent = `${portal.location || portal.name}, ${portal.state}`;
|
||||
el.modalAgencySlug.textContent = `transparency.flocksafety.com/${portal.slug}`;
|
||||
el.modalPortalUrl.href = portal.url;
|
||||
|
||||
// Stats
|
||||
el.modalStatCameras.textContent = portal.cameras ? portal.cameras.toLocaleString() : 'Not Reported';
|
||||
el.modalStatVehicles.textContent = portal.vehicles_captured ? portal.vehicles_captured.toLocaleString() : 'Not Reported';
|
||||
el.modalStatSearches.textContent = portal.searches ? portal.searches.toLocaleString() : '0';
|
||||
el.modalStatHits.textContent = portal.hotlist_hits ? portal.hotlist_hits.toLocaleString() : '0';
|
||||
|
||||
// Search Justifications / Reasons
|
||||
const reasons = portal.top_search_reasons || {};
|
||||
const reasonEntries = Object.entries(reasons).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (reasonEntries.length > 0) {
|
||||
el.modalReasonsSection.style.display = 'block';
|
||||
el.modalReasonsList.innerHTML = reasonEntries.map(([reason, count]) => `
|
||||
<div class="reason-item">
|
||||
<span class="reason-name">${escapeHtml(reason)}</span>
|
||||
<span class="reason-count">${count.toLocaleString()} searches</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
el.modalReasonsSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Inter-Agency Sharing Network
|
||||
const sharedIds = portal.shared_org_ids || [];
|
||||
el.modalSharingCount.textContent = sharedIds.length.toLocaleString();
|
||||
renderModalSharingTags(sharedIds, '');
|
||||
|
||||
// Reset sharing search input
|
||||
el.modalSharingSearch.value = '';
|
||||
|
||||
// Prohibited Uses Text
|
||||
el.modalProhibitedText.textContent = portal.prohibited_uses ||
|
||||
'Prohibited uses standardly include: Immigration enforcement, general traffic speed citations, harassment, or personal/non-official inquiries.';
|
||||
|
||||
// Research Tool Links
|
||||
const googleQuery = encodeURIComponent(`"${portal.name}" "Flock Safety" contract OR council OR resolution`);
|
||||
el.modalGoogleContractLink.href = `https://www.google.com/search?q=${googleQuery}`;
|
||||
|
||||
// Set URL Hash
|
||||
window.location.hash = portal.slug;
|
||||
|
||||
// Open Native Dialog
|
||||
if (typeof el.agencyModal.showModal === 'function') {
|
||||
el.agencyModal.showModal();
|
||||
} else {
|
||||
el.agencyModal.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalSharingTags(sharedIds, query) {
|
||||
if (!sharedIds || sharedIds.length === 0) {
|
||||
el.modalSharingBox.innerHTML = `<span style="font-size: 0.8rem; color: var(--text-dim); padding: 8px;">No external sharing partners published on portal.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
let orgNames = sharedIds.map(id => state.orgsLookup[id] || 'Unknown Agency');
|
||||
|
||||
if (q) {
|
||||
orgNames = orgNames.filter(name => name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
if (orgNames.length === 0) {
|
||||
el.modalSharingBox.innerHTML = `<span style="font-size: 0.8rem; color: var(--text-dim); padding: 8px;">No matching partner agencies.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show up to 200 partner agencies in the box
|
||||
const displayOrgs = orgNames.slice(0, 250);
|
||||
const tagsHtml = displayOrgs.map(name => `
|
||||
<span class="sharing-tag">${escapeHtml(name)}</span>
|
||||
`).join('');
|
||||
|
||||
const moreNotice = orgNames.length > 250
|
||||
? `<span style="font-size: 0.72rem; color: var(--text-dim); padding: 4px;">...and ${(orgNames.length - 250).toLocaleString()} more. Use search to filter.</span>`
|
||||
: '';
|
||||
|
||||
el.modalSharingBox.innerHTML = tagsHtml + moreNotice;
|
||||
}
|
||||
|
||||
function closeAgencyModal() {
|
||||
if (typeof el.agencyModal.close === 'function') {
|
||||
el.agencyModal.close();
|
||||
} else {
|
||||
el.agencyModal.removeAttribute('open');
|
||||
}
|
||||
|
||||
state.activeModalPortal = null;
|
||||
|
||||
// Clear hash without reloading
|
||||
history.replaceState(null, document.title, window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
// --- CSV EXPORT FUNCTIONALITY ---
|
||||
function exportFilteredToCsv() {
|
||||
const list = state.filteredPortals;
|
||||
if (!list || list.length === 0) {
|
||||
showToast('No portals to export.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Agency Name',
|
||||
'State',
|
||||
'Location',
|
||||
'Department Type',
|
||||
'Cameras Reported',
|
||||
'30-Day Vehicle Captures',
|
||||
'Searches Conducted',
|
||||
'Hotlist Alerts',
|
||||
'Public Search Audit',
|
||||
'Shared Agency Count',
|
||||
'Transparency Portal URL'
|
||||
];
|
||||
|
||||
const rows = list.map(p => [
|
||||
`"${(p.name || '').replace(/"/g, '""')}"`,
|
||||
`"${p.state || ''}"`,
|
||||
`"${(p.location || '').replace(/"/g, '""')}"`,
|
||||
`"${p.dept_type || ''}"`,
|
||||
p.cameras || 0,
|
||||
p.vehicles_captured || 0,
|
||||
p.searches || 0,
|
||||
p.hotlist_hits || 0,
|
||||
p.has_search_audit ? 'Yes' : 'No',
|
||||
p.shared_count || 0,
|
||||
`"${p.url || ''}"`
|
||||
]);
|
||||
|
||||
const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `flock_transparency_portals_${state.selectedState.toLowerCase()}_${timestamp}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast(`Exported ${list.length.toLocaleString()} portals to CSV.`, 'success');
|
||||
}
|
||||
|
||||
// --- TOAST NOTIFICATIONS ---
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
|
||||
let icon = 'ℹ️';
|
||||
if (type === 'success') icon = '✓';
|
||||
if (type === 'error') icon = '⚠️';
|
||||
if (type === 'warning') icon = '⚡';
|
||||
|
||||
toast.innerHTML = `<span>${icon}</span><span>${escapeHtml(message)}</span>`;
|
||||
el.toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateY(8px)';
|
||||
toast.style.transition = '0.25s ease';
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
// --- VIEW MODE TOGGLE ---
|
||||
function updateViewMode() {
|
||||
if (state.currentView === 'cards') {
|
||||
el.portalsGrid.style.display = 'grid';
|
||||
el.tableContainer.style.display = 'none';
|
||||
el.viewCardsBtn.classList.add('active');
|
||||
el.viewTableBtn.classList.remove('active');
|
||||
el.viewCardsBtn.setAttribute('aria-checked', 'true');
|
||||
el.viewTableBtn.setAttribute('aria-checked', 'false');
|
||||
} else {
|
||||
el.portalsGrid.style.display = 'none';
|
||||
el.tableContainer.style.display = 'block';
|
||||
el.viewCardsBtn.classList.remove('active');
|
||||
el.viewTableBtn.classList.add('active');
|
||||
el.viewCardsBtn.setAttribute('aria-checked', 'false');
|
||||
el.viewTableBtn.setAttribute('aria-checked', 'true');
|
||||
}
|
||||
localStorage.setItem('flock_dir_view', state.currentView);
|
||||
}
|
||||
|
||||
// --- RESET FILTERS ---
|
||||
function resetAllFilters() {
|
||||
state.searchQuery = '';
|
||||
state.selectedState = 'ALL';
|
||||
state.selectedType = 'ALL';
|
||||
state.auditOnly = false;
|
||||
state.sortBy = 'cameras-desc';
|
||||
|
||||
el.searchInput.value = '';
|
||||
el.clearSearchBtn.style.display = 'none';
|
||||
el.stateFilter.value = 'ALL';
|
||||
el.typeFilter.value = 'ALL';
|
||||
el.sortSelect.value = 'cameras-desc';
|
||||
el.auditToggle.checked = false;
|
||||
|
||||
renderStateChips();
|
||||
applyFilters();
|
||||
showToast('Filters reset to default view.');
|
||||
}
|
||||
|
||||
// --- URL HASH CHECK ---
|
||||
function checkUrlHash() {
|
||||
const hash = window.location.hash.replace('#', '').trim();
|
||||
if (hash) {
|
||||
const match = state.allPortals.find(p => p.slug === hash);
|
||||
if (match) {
|
||||
openAgencyModal(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- EVENT BINDINGS ---
|
||||
function bindEvents() {
|
||||
// Search input with debounce
|
||||
let debounceTimer;
|
||||
el.searchInput.addEventListener('input', e => {
|
||||
clearTimeout(debounceTimer);
|
||||
state.searchQuery = e.target.value;
|
||||
el.clearSearchBtn.style.display = state.searchQuery ? 'block' : 'none';
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
applyFilters();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Clear search button
|
||||
el.clearSearchBtn.addEventListener('click', () => {
|
||||
el.searchInput.value = '';
|
||||
state.searchQuery = '';
|
||||
el.clearSearchBtn.style.display = 'none';
|
||||
el.searchInput.focus();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Keyboard shortcut '/' to search
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.key === '/' && document.activeElement !== el.searchInput) {
|
||||
e.preventDefault();
|
||||
el.searchInput.focus();
|
||||
el.searchInput.select();
|
||||
}
|
||||
});
|
||||
|
||||
// State dropdown filter
|
||||
el.stateFilter.addEventListener('change', e => {
|
||||
state.selectedState = e.target.value;
|
||||
renderStateChips();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// State chips click
|
||||
el.stateChipsScroll.addEventListener('click', e => {
|
||||
const chip = e.target.closest('.state-chip');
|
||||
if (!chip) return;
|
||||
const st = chip.getAttribute('data-state');
|
||||
if (!st) return;
|
||||
|
||||
state.selectedState = st;
|
||||
el.stateFilter.value = st;
|
||||
renderStateChips();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Department type filter
|
||||
el.typeFilter.addEventListener('change', e => {
|
||||
state.selectedType = e.target.value;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Sort select
|
||||
el.sortSelect.addEventListener('change', e => {
|
||||
state.sortBy = e.target.value;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Public Audit toggle
|
||||
el.auditToggle.addEventListener('change', e => {
|
||||
state.auditOnly = e.target.checked;
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Reset all filters button
|
||||
el.btnResetAll.addEventListener('click', resetAllFilters);
|
||||
|
||||
// View toggle buttons
|
||||
el.viewCardsBtn.addEventListener('click', () => {
|
||||
state.currentView = 'cards';
|
||||
updateViewMode();
|
||||
renderDirectory();
|
||||
});
|
||||
|
||||
el.viewTableBtn.addEventListener('click', () => {
|
||||
state.currentView = 'table';
|
||||
updateViewMode();
|
||||
renderDirectory();
|
||||
});
|
||||
|
||||
// Export CSV button
|
||||
el.btnExportCsv.addEventListener('click', exportFilteredToCsv);
|
||||
|
||||
// Card / Table click delegation for Inspect buttons
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-action="inspect"]');
|
||||
if (btn) {
|
||||
const slug = btn.getAttribute('data-slug');
|
||||
if (slug) openAgencyModal(slug);
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination button clicks
|
||||
el.paginationControls.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.pagination-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
if (btn.id === 'pg-prev') {
|
||||
state.currentPage = Math.max(1, state.currentPage - 1);
|
||||
} else if (btn.id === 'pg-next') {
|
||||
const maxPage = Math.ceil(state.filteredPortals.length / state.pageSize);
|
||||
state.currentPage = Math.min(maxPage, state.currentPage + 1);
|
||||
} else {
|
||||
const pageNum = parseInt(btn.getAttribute('data-page'), 10);
|
||||
if (pageNum) state.currentPage = pageNum;
|
||||
}
|
||||
|
||||
renderDirectory();
|
||||
// Smooth scroll back to top of directory
|
||||
document.getElementById('directory-section').scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Modal Close button
|
||||
el.modalCloseBtn.addEventListener('click', closeAgencyModal);
|
||||
|
||||
// Modal Click-Outside Backdrop Dismissal
|
||||
el.agencyModal.addEventListener('click', e => {
|
||||
const rect = el.agencyModal.getBoundingClientRect();
|
||||
const isInDialog = (
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width
|
||||
);
|
||||
if (!isInDialog) {
|
||||
closeAgencyModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal ESC Key
|
||||
el.agencyModal.addEventListener('cancel', e => {
|
||||
e.preventDefault();
|
||||
closeAgencyModal();
|
||||
});
|
||||
|
||||
// Modal Sharing Partners live search input
|
||||
el.modalSharingSearch.addEventListener('input', e => {
|
||||
if (!state.activeModalPortal) return;
|
||||
const sharedIds = state.activeModalPortal.shared_org_ids || [];
|
||||
renderModalSharingTags(sharedIds, e.target.value);
|
||||
});
|
||||
|
||||
// Modal Copy Link Button
|
||||
el.modalCopyLinkBtn.addEventListener('click', () => {
|
||||
if (!state.activeModalPortal) return;
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}#${state.activeModalPortal.slug}`;
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
showToast('Direct link copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
showToast(`Link: ${shareUrl}`, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
// Hash change event (forward/back browser navigation)
|
||||
window.addEventListener('hashchange', checkUrlHash);
|
||||
}
|
||||
|
||||
// --- UTILITY HELPERS ---
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatNumberCompact(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(0) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
// --- START APP ON DOM READY ---
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* FLOCKRADAR - USER AUTHENTICATION & COMMUNITY STATE MANAGER
|
||||
* Waze-style driver profile, preferences, and crowdsourced reporting store.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY_USER = 'flock_radar_user';
|
||||
const STORAGE_KEY_REPORTS = 'flock_community_reports';
|
||||
|
||||
// Default Guest Profile
|
||||
const defaultUser = {
|
||||
isLoggedIn: false,
|
||||
username: 'Guest Driver',
|
||||
callsign: 'SCOUT-104',
|
||||
level: 1,
|
||||
points: 120,
|
||||
vehicle: 'Sedan (Silver)',
|
||||
soundEnabled: true,
|
||||
alertsMuted: false,
|
||||
reportsCount: 0
|
||||
};
|
||||
|
||||
// State
|
||||
let currentUser = null;
|
||||
let communityReports = [];
|
||||
|
||||
function init() {
|
||||
loadUser();
|
||||
loadReports();
|
||||
}
|
||||
|
||||
function loadUser() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_USER);
|
||||
if (stored) {
|
||||
currentUser = JSON.parse(stored);
|
||||
} else {
|
||||
currentUser = { ...defaultUser };
|
||||
}
|
||||
} catch (e) {
|
||||
currentUser = { ...defaultUser };
|
||||
}
|
||||
}
|
||||
|
||||
function saveUser() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_USER, JSON.stringify(currentUser));
|
||||
window.dispatchEvent(new CustomEvent('flock_user_updated', { detail: currentUser }));
|
||||
} catch (e) {
|
||||
console.error('Failed to save user session', e);
|
||||
}
|
||||
}
|
||||
|
||||
function login(username, vehicle, soundEnabled) {
|
||||
currentUser = {
|
||||
isLoggedIn: true,
|
||||
username: username || 'Citizen Scout',
|
||||
callsign: 'RADAR-' + Math.floor(100 + Math.random() * 900),
|
||||
level: 2,
|
||||
points: 250,
|
||||
vehicle: vehicle || 'Standard Vehicle',
|
||||
soundEnabled: soundEnabled !== false,
|
||||
alertsMuted: false,
|
||||
reportsCount: communityReports.length
|
||||
};
|
||||
saveUser();
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
currentUser = { ...defaultUser };
|
||||
saveUser();
|
||||
}
|
||||
|
||||
function loadReports() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_REPORTS);
|
||||
if (stored) {
|
||||
communityReports = JSON.parse(stored);
|
||||
} else {
|
||||
communityReports = [];
|
||||
}
|
||||
} catch (e) {
|
||||
communityReports = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveReports() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
|
||||
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
|
||||
} catch (e) {
|
||||
console.error('Failed to save community reports', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addReport(report) {
|
||||
const newReport = {
|
||||
id: 'rpt-' + Date.now(),
|
||||
lat: report.lat,
|
||||
lng: report.lng,
|
||||
type: report.type || 'Solar Pole Mount',
|
||||
intersection: report.intersection || 'Reported Location',
|
||||
notes: report.notes || 'Spotted by community driver',
|
||||
reportedBy: currentUser ? currentUser.username : 'Anonymous Scout',
|
||||
timestamp: new Date().toISOString(),
|
||||
confirmations: 1
|
||||
};
|
||||
|
||||
communityReports.push(newReport);
|
||||
saveReports();
|
||||
|
||||
if (currentUser) {
|
||||
currentUser.points = (currentUser.points || 0) + 50;
|
||||
currentUser.reportsCount = (currentUser.reportsCount || 0) + 1;
|
||||
saveUser();
|
||||
}
|
||||
|
||||
return newReport;
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
if (!currentUser) loadUser();
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
function getReports() {
|
||||
if (!communityReports) loadReports();
|
||||
return communityReports;
|
||||
}
|
||||
|
||||
// Export API to global scope
|
||||
window.FlockAuth = {
|
||||
init,
|
||||
getUser,
|
||||
login,
|
||||
logout,
|
||||
saveUser,
|
||||
getReports,
|
||||
addReport
|
||||
};
|
||||
|
||||
init();
|
||||
})();
|
||||
+1453
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "FlockRadar - ALPR Awareness & Live Navigation",
|
||||
"short_name": "FlockRadar",
|
||||
"description": "Real-time ALPR radar navigation app alerting drivers to active Flock camera recorded zones.",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#07090e",
|
||||
"theme_color": "#07090e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%2307090e'/%3E%3Ccircle cx='50' cy='50' r='38' fill='%23ef4444' opacity='0.25'/%3E%3Ccircle cx='50' cy='50' r='20' fill='%23ef4444'/%3E%3Ccircle cx='50' cy='50' r='8' fill='%23ffffff'/%3E%3C/svg%3E",
|
||||
"sizes": "192x192 512x512",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import http.server
|
||||
import socketserver
|
||||
import ssl
|
||||
import threading
|
||||
import sys
|
||||
import os
|
||||
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
class NoCacheHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
self.send_header('Expires', '0')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
super().end_headers()
|
||||
|
||||
def run_http(port=8085):
|
||||
try:
|
||||
class ReusableServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
with ReusableServer(('', port), NoCacheHandler) as httpd:
|
||||
print(f'Serving HTTP on port {port} (http://localhost:{port} and http://192.168.1.249:{port})')
|
||||
sys.stdout.flush()
|
||||
httpd.serve_forever()
|
||||
except Exception as e:
|
||||
print(f'HTTP Server on {port} notice: {e}')
|
||||
sys.stdout.flush()
|
||||
|
||||
def run_https(port=8443):
|
||||
try:
|
||||
class ReusableServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
with ReusableServer(('', port), NoCacheHandler) as httpd:
|
||||
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
ctx.load_cert_chain(certfile='cert.pem', keyfile='key.pem')
|
||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||
print(f'Serving HTTPS on port {port} (https://localhost:{port} and https://192.168.1.249:{port})')
|
||||
sys.stdout.flush()
|
||||
httpd.serve_forever()
|
||||
except Exception as e:
|
||||
print(f'HTTPS Server on {port} notice: {e}')
|
||||
sys.stdout.flush()
|
||||
|
||||
if __name__ == '__main__':
|
||||
t_https = threading.Thread(target=run_https, args=(8443,), daemon=True)
|
||||
t_https.start()
|
||||
run_http(8085)
|
||||
@@ -0,0 +1,35 @@
|
||||
// Service Worker for FlockRadar Offline Driving & Caching
|
||||
const CACHE_NAME = 'flockradar-v2';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'./',
|
||||
'./index.html',
|
||||
'./css/styles.css',
|
||||
'./js/app.js',
|
||||
'./js/auth.js',
|
||||
'./js/radar.js',
|
||||
'./data/portals.js',
|
||||
'./manifest.json'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS_TO_CACHE))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
|
||||
))
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
// Network first, fallback to cache
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user