krz/omaha-metro-blotter

Archive of police activity and ALPR surveillance across the Omaha metro. alpr archive omaha police surveillance

Commit f3030ed557

f3030ed557395eeacdde22a1f75b4de834e8d6d7

parent: a390e12948

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 19:04 UTC

Add a static public site and a Flock export reminder

build_site.py precomputes every figure from the archive into one
self-contained site/index.html — 38 KB of data, no server, no fetch,
no dependencies. A second workflow job builds it after each pull and
deploys to GitHub Pages; app.py stays as the exploration tool.

Figures: stop outcomes by agency, distance from stops to the nearest
camera against a non-stop baseline, a stop-density map with camera
positions, and the Council Bluffs search audit. Each carries the
caveat that makes it readable — differing disposition vocabularies,
the arterial-road confound, Omaha publishing no stop data.

The Flock export cannot be automated, so the pull job warns at 21
days since the last one and fails at 27, before the portal's 30-day
window closes.

Layout: unified · split

.github/workflows/daily-pull.yml +59
@@ -23,6 +23,8 @@ on:
23 23
24permissions: 24permissions:
25 contents: write 25 contents: write
26 pages: write
27 id-token: write
26 28
27concurrency: 29concurrency:
28 group: archive 30 group: archive
@@ -192,6 +194,28 @@ jobs:
192 fi 194 fi
193 echo "all feeds current" 195 echo "all feeds current"
194 196
197 # The Flock export is the one thing here that cannot be automated: the
198 # portal challenges every non-browser client. Its window is 30 days, so
199 # this fails at 27 while there is still time to act, not afterwards.
200 - name: Check the Flock export is current
201 run: |
202 age=$(sqlite3 -noheader "$DB" "
203 SELECT CAST(julianday('now') - julianday(MAX(imported_at)) AS INT)
204 FROM alpr_searches" 2>/dev/null || echo "")
205 if [ -z "$age" ] || [ "$age" = "" ]; then
206 echo "no Flock export on file yet"; exit 0
207 fi
208 echo "newest Flock export imported $age days ago"
209 if [ "$age" -ge 27 ]; then
210 echo "::error::Flock search audit is $age days old and the portal only" \
211 "keeps 30. Download it from" \
212 "https://transparency.flocksafety.com/council-bluffs-ia-pd and commit" \
213 "it to raw_data/flock/ before the window closes."
214 exit 1
215 elif [ "$age" -ge 21 ]; then
216 echo "::warning::Flock search audit is $age days old; refresh it soon."
217 fi
218
195 - name: Summary 219 - name: Summary
196 if: always() 220 if: always()
197 run: | 221 run: |
@@ -203,3 +227,38 @@ jobs:
203 { printf "| %s | %s | %s |\n", $1, ($1 in was ? was[$1] : 0), $2 }' \ 227 { printf "| %s | %s | %s |\n", $1, ($1 in was ? was[$1] : 0), $2 }' \
204 before.txt after.txt 228 before.txt after.txt
205 } >> "$GITHUB_STEP_SUMMARY" 229 } >> "$GITHUB_STEP_SUMMARY"
230
231 # Separate job: the site needs pandas and numpy, and a Pages failure must not
232 # put the archive at risk.
233 site:
234 needs: pull
235 runs-on: ubuntu-latest
236 environment:
237 name: github-pages
238 url: ${{ steps.deploy.outputs.page_url }}
239
240 steps:
241 - uses: actions/checkout@v4
242
243 - uses: actions/setup-python@v5
244 with:
245 python-version: "3.13"
246 cache: pip
247 cache-dependency-path: requirements.txt
248
249 - run: pip install -r requirements.txt
250
251 - name: Fetch the published archive
252 run: |
253 gh release download "$TAG" --pattern metro.db.gz --dir .
254 mkdir -p raw_data
255 gunzip -c metro.db.gz > "$DB"
256
257 - run: python build_site.py
258
259 - uses: actions/configure-pages@v5
260 - uses: actions/upload-pages-artifact@v3
261 with:
262 path: site
263 - id: deploy
264 uses: actions/deploy-pages@v4
.gitignore +1
@@ -2,3 +2,4 @@
2*.db 2*.db
3.venv/ 3.venv/
4__pycache__/ 4__pycache__/
5site/
README.nfo +13 −1
@@ -45,6 +45,16 @@ USE
45 .venv/bin/python ingest.py opd_csv # 2015-2023 csv archive 45 .venv/bin/python ingest.py opd_csv # 2015-2023 csv archive
46 .venv/bin/python app.py 46 .venv/bin/python app.py
47 47
48SITE
49 build_site.py precomputes every figure into one self-contained
50 site/index.html -- no server, no fetch, no dependencies, 38 kb of
51 data. the workflow rebuilds it after each pull and deploys it to
52 github pages. app.py stays as the exploration tool; it needs a live
53 process and refilters 300k rows per interaction, which is fine for
54 one person and wrong for the public.
55
56 .venv/bin/python build_site.py && open site/index.html
57
48ARCHIVE 58ARCHIVE
49 .github/workflows/daily-pull.yml runs at 11:17 and 23:17 utc and 59 .github/workflows/daily-pull.yml runs at 11:17 and 23:17 utc and
50 keeps the database as metro.db.gz on the "archive" release, so the 60 keeps the database as metro.db.gz on the "archive" release, so the
@@ -98,7 +108,9 @@ FLOCK SEARCH AUDIT
98 job and picks up whatever is committed. search ids are stable 108 job and picks up whatever is committed. search ids are stable
99 uuids, so overlapping exports dedupe. 109 uuids, so overlapping exports dedupe.
100 110
101 the portals keep 30 days. miss a month and that month is gone. 111 the portals keep 30 days. miss a month and that month is gone, so
112 the workflow warns at 21 days since the last export and fails the
113 run at 27, while there is still time to act.
102 114
103 as of the first export, 100 of 442 council bluffs searches carried 115 as of the first export, 100 of 442 council bluffs searches carried
104 any reason at all, against an access policy stating that all access 116 any reason at all, against an access policy stating that all access
build_site.py added +155
@@ -0,0 +1,155 @@
1"""Precompute the public site's figures into a self-contained site/index.html.
2
3The dashboard in app.py needs a live Python process and refilters 300k rows on
4every interaction. This does the aggregation once, at ingest time, and emits one
5static file: no server, no dependencies, no per-visit cost."""
6
7import json
8import sqlite3
9from datetime import datetime, timezone
10from pathlib import Path
11
12import numpy as np
13
14import analysis
15
16ROOT = Path(__file__).parent
17OUT = ROOT / "site"
18# Grid cells for the map. ~0.004 deg is roughly 300m of latitude here, fine
19# enough to show which corridors stops sit on without shipping 40k points.
20CELL = 0.004
21
22
23def summary(conn):
24 rows = conn.execute(
25 """SELECT agency, COUNT(*), SUM(is_stop),
26 MIN(occurred_at), MAX(occurred_at)
27 FROM incidents_current GROUP BY agency ORDER BY COUNT(*) DESC"""
28 ).fetchall()
29 return [{"agency": a, "incidents": n, "stops": s or 0,
30 "first": lo[:10], "last": hi[:10]} for a, n, s, lo, hi in rows]
31
32
33def totals(conn):
34 q = lambda sql: conn.execute(sql).fetchone()[0]
35 return {
36 "incidents": q("SELECT COUNT(*) FROM incidents_current"),
37 "stops": q("SELECT COUNT(*) FROM incidents_current WHERE is_stop=1"),
38 "cameras": q("SELECT COUNT(*) FROM alpr_cameras"),
39 "searches": q("SELECT COUNT(*) FROM alpr_searches"),
40 "amendments": q("SELECT COUNT(*) FROM incident_amendments"),
41 "raw": q("SELECT COUNT(*) FROM raw_records"),
42 "built": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
43 }
44
45
46def outcomes(conn):
47 df = analysis.stop_outcomes(analysis.load_incidents(conn))
48 if df.empty:
49 return []
50 wide = df.pivot(index="agency", columns="outcome", values="rate")
51 counts = df.groupby("agency")["stops"].first()
52 return [{"agency": a, "stops": int(counts[a]),
53 "cited": round(float(wide.loc[a, "Cited"]), 4),
54 "arrested": round(float(wide.loc[a, "Arrested"]), 4)}
55 for a in wide.sort_values("Cited", ascending=False).index]
56
57
58def proximity(conn):
59 df = analysis.load_incidents(conn)
60 cams = analysis.load_cameras(conn)
61 prox = analysis.camera_proximity(df, cams)
62 if prox.empty:
63 return []
64 wide = prox.pivot(index="distance_m", columns="kind", values="share")
65 return [{"m": int(m),
66 "stops": round(float(r.get("Vehicle stops", 0)), 5),
67 "other": round(float(r.get("All other incidents", 0)), 5)}
68 for m, r in wide.iterrows()]
69
70
71def weekly(conn):
72 df = analysis.load_incidents(conn)
73 if df.empty:
74 return {}
75 w = (df.assign(week=df["occurred_at"].dt.to_period("W").dt.start_time)
76 .groupby(["agency", "week"]).size().reset_index(name="n"))
77 out = {}
78 for agency, g in w.groupby("agency"):
79 g = g.sort_values("week")
80 # drop the trailing partial week so the last point is not a false dip
81 g = g.iloc[:-1] if len(g) > 1 else g
82 out[agency] = {"weeks": [d.strftime("%Y-%m-%d") for d in g["week"]],
83 "counts": [int(x) for x in g["n"]]}
84 return out
85
86
87def map_layers(conn):
88 df = analysis.load_incidents(conn)
89 stops = df[df["is_stop"] == 1].dropna(subset=["lat", "lon"])
90 lat = (np.floor(stops["lat"] / CELL) * CELL).round(4)
91 lon = (np.floor(stops["lon"] / CELL) * CELL).round(4)
92 grid = (stops.assign(clat=lat, clon=lon)
93 .groupby(["clat", "clon"]).size().reset_index(name="n"))
94 grid = grid[grid["n"] >= 2] # single stops are noise at this zoom
95 cams = analysis.load_cameras(conn)
96 return {
97 "cell": CELL,
98 "cells": [[round(r.clat, 4), round(r.clon, 4), int(r.n)]
99 for r in grid.itertuples()],
100 "cameras": [[round(r.lat, 5), round(r.lon, 5)] for r in cams.itertuples()],
101 }
102
103
104def searches(conn):
105 audit = analysis.search_audit(conn)
106 if audit.empty:
107 return None
108 row = audit.iloc[0]
109 counts = [r[0] for r in conn.execute(
110 "SELECT network_count FROM alpr_searches WHERE network_count IS NOT NULL")]
111 reasons = conn.execute(
112 """SELECT LOWER(reason), COUNT(*) FROM alpr_searches
113 WHERE reason IS NOT NULL GROUP BY 1 ORDER BY 2 DESC LIMIT 8""").fetchall()
114 edges = [0, 1, 10, 100, 500, 1000, 2500, 10000]
115 hist = []
116 for lo, hi in zip(edges, edges[1:]):
117 hist.append({"lo": lo, "hi": hi,
118 "n": sum(1 for c in counts if lo < c <= hi)})
119 return {
120 "agency": row["agency"], "searches": int(row["searches"]),
121 "with_reason": int(row["with_reason"]),
122 "reason_rate": round(float(row["reason_rate"]), 4),
123 "median_networks": int(np.median(counts)) if counts else 0,
124 "max_networks": int(max(counts)) if counts else 0,
125 "first": row["earliest"][:10], "last": row["latest"][:10],
126 "histogram": hist,
127 "reasons": [{"reason": r, "n": n} for r, n in reasons],
128 }
129
130
131def build():
132 conn = sqlite3.connect(analysis.DB)
133 data = {
134 "totals": totals(conn),
135 "agencies": summary(conn),
136 "outcomes": outcomes(conn),
137 "proximity": proximity(conn),
138 "weekly": weekly(conn),
139 "map": map_layers(conn),
140 "searches": searches(conn),
141 }
142 conn.close()
143
144 OUT.mkdir(exist_ok=True)
145 template = (ROOT / "site_template.html").read_text()
146 payload = json.dumps(data, separators=(",", ":"))
147 (OUT / "index.html").write_text(template.replace("/*DATA*/null", payload))
148 return data, len(payload)
149
150
151if __name__ == "__main__":
152 data, size = build()
153 print(f" site/index.html written, {size/1024:.0f} KiB of data")
154 for k, v in data["totals"].items():
155 print(f" {k}: {v}")
site_template.html added +467
@@ -0,0 +1,467 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Omaha Metro Police Activity</title>
7<meta name="description" content="Police incidents, vehicle stops and ALPR camera surveillance across the Omaha metro, from the agencies' own published feeds.">
8<style>
9:root {
10 color-scheme: light;
11 --surface-0: #f6f5f3;
12 --surface-1: #fcfcfb;
13 --border: #dedcd6;
14 --text-primary: #0b0b0b;
15 --text-secondary: #52514e;
16 --text-muted: #78766f;
17 --grid: #e8e6e1;
18 --series-1: #2a78d6;
19 --series-2: #eb6834;
20 --seq-1: #cde2fb; --seq-2: #9ec5f4; --seq-3: #6da7ec;
21 --seq-4: #3987e5; --seq-5: #256abf; --seq-6: #0d366b;
22}
23@media (prefers-color-scheme: dark) {
24 :root {
25 color-scheme: dark;
26 --surface-0: #121211;
27 --surface-1: #1a1a19;
28 --border: #34342f;
29 --text-primary: #ffffff;
30 --text-secondary: #c3c2b7;
31 --text-muted: #96958c;
32 --grid: #2c2c28;
33 --series-1: #3987e5;
34 --series-2: #d95926;
35 --seq-1: #104281; --seq-2: #184f95; --seq-3: #256abf;
36 --seq-4: #2a78d6; --seq-5: #5598e7; --seq-6: #9ec5f4;
37 }
38}
39* { box-sizing: border-box; }
40body {
41 margin: 0; background: var(--surface-0); color: var(--text-primary);
42 font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
43 -webkit-font-smoothing: antialiased;
44}
45.wrap { max-width: 1080px; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
46h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .5rem; letter-spacing: -.02em; }
47h2 { font-size: 1.15rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
48p { margin: 0 0 1rem; color: var(--text-secondary); max-width: 68ch; }
49.lede { font-size: 1.05rem; }
50.stamp { color: var(--text-muted); font-size: .85rem; }
51section { margin: 3rem 0 0; }
52.note { font-size: .875rem; color: var(--text-muted); max-width: 68ch; }
53.card {
54 background: var(--surface-1); border: 1px solid var(--border);
55 border-radius: 10px; padding: 1.1rem 1.2rem; margin-top: .9rem;
56}
57.tiles { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); margin-top: 1.5rem; }
58.tile { background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; padding: .85rem 1rem; }
59.tile b { display: block; font-size: 1.65rem; font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
60.tile span { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .1rem; }
61.legend { display: flex; flex-wrap: wrap; gap: 1rem; font-size: .85rem; color: var(--text-secondary); margin: .1rem 0 .8rem; }
62.legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-right: .4rem; vertical-align: -1px; }
63svg { display: block; width: 100%; height: auto; overflow: visible; }
64svg text { fill: var(--text-secondary); font-size: 11px; }
65svg .axis-line { stroke: var(--border); stroke-width: 1; }
66svg .grid-line { stroke: var(--grid); stroke-width: 1; }
67svg .val { fill: var(--text-primary); font-size: 11px; font-variant-numeric: tabular-nums; }
68svg .cat { fill: var(--text-primary); font-size: 12px; }
69.small-mult { display: grid; gap: .9rem; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
70.sm h3 { font-size: .85rem; margin: 0 0 .1rem; font-weight: 600; }
71.sm .sub { font-size: .75rem; color: var(--text-muted); margin-bottom: .2rem; font-variant-numeric: tabular-nums; }
72table { border-collapse: collapse; width: 100%; font-size: .85rem; font-variant-numeric: tabular-nums; }
73th, td { text-align: right; padding: .35rem .6rem; border-bottom: 1px solid var(--border); }
74th:first-child, td:first-child { text-align: left; }
75th { color: var(--text-muted); font-weight: 600; }
76details { margin-top: .8rem; }
77summary { cursor: pointer; font-size: .85rem; color: var(--text-secondary); }
78#tip {
79 position: fixed; pointer-events: none; opacity: 0; transition: opacity .1s;
80 background: var(--surface-1); border: 1px solid var(--border); border-radius: 7px;
81 padding: .4rem .6rem; font-size: .8rem; color: var(--text-primary);
82 box-shadow: 0 4px 14px rgba(0,0,0,.16); z-index: 9; max-width: 260px;
83 font-variant-numeric: tabular-nums;
84}
85.bar-track { fill: var(--grid); }
86.hit { fill: transparent; }
87footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: .85rem; color: var(--text-muted); }
88footer a { color: var(--text-secondary); }
89</style>
90</head>
91<body>
92<div id="tip" role="status" aria-live="polite"></div>
93<div class="wrap">
94<h1>Omaha Metro Police Activity</h1>
95<p class="lede" id="lede"></p>
96<p class="stamp" id="stamp"></p>
97
98<div class="tiles" id="tiles"></div>
99
100<section>
101 <h2>Vehicle stop outcomes</h2>
102 <p>What share of each agency's officer-initiated vehicle stops ended in a citation, and what share in an arrest.</p>
103 <div class="card">
104 <div class="legend" id="lg-out"></div>
105 <div id="c-outcomes"></div>
106 </div>
107 <p class="note">The two CAD systems record outcomes differently. Sarpy County writes <code>WRITTEN WARNING</code> and <code>CITATION</code> explicitly; Council Bluffs folds most non-citation outcomes into &ldquo;Handled by Officer.&rdquo; An agency that records warnings less thoroughly shows a higher citation rate for that reason alone. Omaha PD publishes no stop or disposition data at all and is absent here.</p>
108 <details><summary>Show the numbers</summary><div id="t-outcomes"></div></details>
109</section>
110
111<section>
112 <h2>Distance to the nearest ALPR camera</h2>
113 <p>Where vehicle stops happen relative to automated licence plate readers, against every other kind of incident as a baseline. Both lines are shares of their own series, so the gap is what matters.</p>
114 <div class="card">
115 <div class="legend" id="lg-prox"></div>
116 <div id="c-prox"></div>
117 </div>
118 <p class="note"><strong>This is not evidence that cameras cause stops.</strong> Cameras get mounted on arterial roads and arterial roads are where traffic enforcement happens, so the two concentrate together for reasons that have nothing to do with each other. It is a starting point for asking the agencies a question, not an answer. That question has a stated standard: all three metro Flock transparency portals list <em>traffic enforcement</em> under prohibited uses.</p>
119 <details><summary>Show the numbers</summary><div id="t-prox"></div></details>
120</section>
121
122<section>
123 <h2>Where stops and cameras sit</h2>
124 <p>Vehicle stops aggregated onto a grid, with every mapped ALPR camera drawn on top.</p>
125 <div class="card">
126 <div class="legend" id="lg-map"></div>
127 <div id="c-map"></div>
128 </div>
129 <p class="note">Omaha is blank because Omaha PD publishes no stop data &mdash; the empty area is a gap in what the city reports, not an absence of stops. Camera locations come from OpenStreetMap, mapped by volunteers through the DeFlock project, and are certainly incomplete: Council Bluffs, Sarpy County and Douglas County together report 89 cameras on their own portals, and those three are not the only agencies running them.</p>
130</section>
131
132<section>
133 <h2>ALPR searches</h2>
134 <div id="searches"></div>
135</section>
136
137<section>
138 <h2>Reported volume by agency</h2>
139 <p>Incidents per week, each agency on its own scale. Omaha PD's record begins in 2022; the Sarpy County and Council Bluffs feeds each serve a rolling twelve months, so their history starts when this archive started keeping it.</p>
140 <div class="small-mult" id="c-weekly"></div>
141</section>
142
143<section>
144 <h2>What is and is not here</h2>
145 <div class="card"><div id="t-coverage"></div></div>
146 <p class="note"><strong>Ralston PD publishes no machine-readable feed and does not appear anywhere on this page.</strong> Gretna and Springfield appear in the Sarpy County feed as fire departments only; their police departments do not report to it. Each feed uses its own incident taxonomy and none of them are comparable to each other, so nothing here counts &ldquo;crime&rdquo; across agencies &mdash; only what each agency itself published.</p>
147</section>
148
149<footer>
150 <p>Built from the agencies' own published feeds: the Omaha Police incident view on DCGIS, the Sarpy County PublicCrimeMap, the Council Bluffs public calls-for-service feed, and the Flock Safety transparency portals. Camera locations from OpenStreetMap contributors, <a href="https://opendatacommons.org/licenses/odbl/">ODbL</a>.</p>
151 <p>The Sarpy County and Council Bluffs feeds keep a rolling twelve months and drop what falls outside it. This archive keeps what they drop. Source and the full database: <a href="https://github.com/krazywarez/omaha-incidents">github.com/krazywarez/omaha-incidents</a>.</p>
152</footer>
153</div>
154
155<script>
156const DATA = /*DATA*/null;
157const S1 = 'var(--series-1)', S2 = 'var(--series-2)';
158const fmt = n => n.toLocaleString('en-US');
159const pct = (v, d = 1) => (v * 100).toFixed(d) + '%';
160const tip = document.getElementById('tip');
161
162function showTip(evt, html) {
163 tip.innerHTML = html;
164 tip.style.opacity = 1;
165 const pad = 14, r = tip.getBoundingClientRect();
166 let x = evt.clientX + pad, y = evt.clientY + pad;
167 if (x + r.width > innerWidth - 8) x = evt.clientX - r.width - pad;
168 if (y + r.height > innerHeight - 8) y = evt.clientY - r.height - pad;
169 tip.style.left = x + 'px'; tip.style.top = y + 'px';
170}
171const hideTip = () => { tip.style.opacity = 0; };
172function hoverable(el, html) {
173 el.addEventListener('mousemove', e => showTip(e, html));
174 el.addEventListener('mouseleave', hideTip);
175}
176const el = (tag, attrs = {}, kids = []) => {
177 const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
178 for (const k in attrs) n.setAttribute(k, attrs[k]);
179 kids.forEach(c => n.appendChild(c));
180 return n;
181};
182function legend(id, items) {
183 document.getElementById(id).innerHTML = items
184 .map(([c, l]) => `<span><i style="background:${c}"></i>${l}</span>`).join('');
185}
186function table(id, cols, rows) {
187 document.getElementById(id).innerHTML =
188 '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') +
189 '</tr></thead><tbody>' + rows.map(r => '<tr>' +
190 r.map(c => `<td>${c}</td>`).join('') + '</tr>').join('') + '</tbody></table>';
191}
192
193/* ---- tiles ---- */
194(function () {
195 const t = DATA.totals;
196 document.getElementById('lede').textContent =
197 `${fmt(t.incidents)} incidents and ${fmt(t.stops)} officer-initiated vehicle stops from six law` +
198 ` enforcement agencies, against ${fmt(t.cameras)} mapped licence plate readers and` +
199 ` ${fmt(t.searches)} published searches of one agency's camera network.`;
200 document.getElementById('stamp').textContent = 'Rebuilt ' + t.built + '.';
201 const tiles = [
202 [fmt(t.incidents), 'Incidents'], [fmt(t.stops), 'Vehicle stops'],
203 [fmt(t.cameras), 'ALPR cameras'], [fmt(t.searches), 'Published searches'],
204 [fmt(t.raw), 'Raw payloads kept'],
205 ];
206 document.getElementById('tiles').innerHTML = tiles
207 .map(([v, l]) => `<div class="tile"><b>${v}</b><span>${l}</span></div>`).join('');
208})();
209
210/* ---- stop outcomes: grouped horizontal bars ---- */
211(function () {
212 const d = DATA.outcomes;
213 if (!d.length) return;
214 legend('lg-out', [[S1, 'Cited'], [S2, 'Arrested']]);
215 const W = 720, rowH = 52, padL = 150, padR = 64, top = 8;
216 const H = top + d.length * rowH + 26;
217 const max = Math.max(...d.map(r => r.cited)) * 1.12;
218 const x = v => padL + (v / max) * (W - padL - padR);
219 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
220 'aria-label': 'Citation and arrest rate on vehicle stops, by agency' });
221
222 [0, .2, .4, .6].forEach(g => {
223 if (g > max) return;
224 svg.appendChild(el('line', { class: 'grid-line', x1: x(g), x2: x(g), y1: top, y2: top + d.length * rowH }));
225 const tx = el('text', { x: x(g), y: H - 8, 'text-anchor': 'middle' });
226 tx.textContent = pct(g, 0); svg.appendChild(tx);
227 });
228
229 d.forEach((r, i) => {
230 const yTop = top + i * rowH;
231 const name = el('text', { x: padL - 12, y: yTop + 26, 'text-anchor': 'end', class: 'cat' });
232 name.textContent = r.agency; svg.appendChild(name);
233 // 2px surface gap between the paired bars
234 [['cited', S1, yTop + 8, 15], ['arrested', S2, yTop + 25, 15]].forEach(([k, col, y, h]) => {
235 const w = Math.max(x(r[k]) - padL, 0);
236 const bar = el('rect', { x: padL, y, width: w, height: h, rx: 4, fill: col });
237 svg.appendChild(bar);
238 const lab = el('text', { x: padL + w + 8, y: y + h - 3, class: 'val' });
239 lab.textContent = pct(r[k], k === 'arrested' ? 2 : 1);
240 svg.appendChild(lab);
241 const hit = el('rect', { class: 'hit', x: padL, y: y - 2, width: W - padL, height: h + 4 });
242 svg.appendChild(hit);
243 hoverable(hit, `<b>${r.agency}</b><br>${k === 'cited' ? 'Cited' : 'Arrested'}: ` +
244 `${pct(r[k], 2)} of ${fmt(r.stops)} stops`);
245 });
246 });
247 document.getElementById('c-outcomes').appendChild(svg);
248 table('t-outcomes', ['Agency', 'Stops', 'Cited', 'Arrested'],
249 d.map(r => [r.agency, fmt(r.stops), pct(r.cited), pct(r.arrested, 2)]));
250})();
251
252/* ---- distance to nearest camera: two lines + crosshair ---- */
253(function () {
254 const d = DATA.proximity;
255 if (!d.length) return;
256 legend('lg-prox', [[S2, 'Vehicle stops'], [S1, 'All other incidents']]);
257 const W = 720, H = 300, padL = 52, padR = 96, padT = 12, padB = 44;
258 const maxY = Math.max(...d.map(r => Math.max(r.stops, r.other))) * 1.1;
259 const maxX = Math.max(...d.map(r => r.m));
260 const x = v => padL + (v / maxX) * (W - padL - padR);
261 const y = v => H - padB - (v / maxY) * (H - padT - padB);
262 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
263 'aria-label': 'Share of incidents by distance to the nearest ALPR camera' });
264
265 for (let g = 0; g <= maxY; g += 0.04) {
266 svg.appendChild(el('line', { class: 'grid-line', x1: padL, x2: W - padR, y1: y(g), y2: y(g) }));
267 const t = el('text', { x: padL - 10, y: y(g) + 4, 'text-anchor': 'end' });
268 t.textContent = pct(g, 0); svg.appendChild(t);
269 }
270 d.forEach(r => {
271 const t = el('text', { x: x(r.m), y: H - padB + 18, 'text-anchor': 'middle' });
272 t.textContent = r.m; svg.appendChild(t);
273 });
274 const ax = el('text', { x: (padL + W - padR) / 2, y: H - 6, 'text-anchor': 'middle' });
275 ax.textContent = 'metres to the nearest camera'; svg.appendChild(ax);
276
277 const path = (key, col) => {
278 const dstr = d.map((r, i) => `${i ? 'L' : 'M'}${x(r.m)},${y(r[key])}`).join(' ');
279 svg.appendChild(el('path', { d: dstr, fill: 'none', stroke: col,
280 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
281 d.forEach(r => svg.appendChild(el('circle', { cx: x(r.m), cy: y(r[key]), r: 4,
282 fill: col, stroke: 'var(--surface-1)', 'stroke-width': 2 })));
283 const last = d[d.length - 1];
284 const lab = el('text', { x: x(last.m) + 10, y: y(last[key]) + 4, class: 'val' });
285 lab.textContent = key === 'stops' ? 'stops' : 'other'; svg.appendChild(lab);
286 };
287 path('other', S1); path('stops', S2);
288
289 const cross = el('line', { class: 'axis-line', y1: padT, y2: H - padB, opacity: 0 });
290 svg.appendChild(cross);
291 const band = el('rect', { class: 'hit', x: padL, y: padT, width: W - padL - padR, height: H - padT - padB });
292 svg.appendChild(band);
293 band.addEventListener('mousemove', e => {
294 const box = svg.getBoundingClientRect();
295 const vx = (e.clientX - box.left) / box.width * W;
296 let best = d[0];
297 d.forEach(r => { if (Math.abs(x(r.m) - vx) < Math.abs(x(best.m) - vx)) best = r; });
298 cross.setAttribute('x1', x(best.m)); cross.setAttribute('x2', x(best.m));
299 cross.setAttribute('opacity', 1);
300 showTip(e, `<b>${best.m}–${best.m + (d[1] ? d[1].m - d[0].m : 200)} m</b><br>` +
301 `Vehicle stops: ${pct(best.stops)}<br>All other: ${pct(best.other)}`);
302 });
303 band.addEventListener('mouseleave', () => { cross.setAttribute('opacity', 0); hideTip(); });
304 document.getElementById('c-prox').appendChild(svg);
305 table('t-prox', ['Distance (m)', 'Vehicle stops', 'All other incidents'],
306 d.map(r => [r.m, pct(r.stops, 2), pct(r.other, 2)]));
307})();
308
309/* ---- map: stop-density grid + cameras ---- */
310(function () {
311 const m = DATA.map;
312 if (!m || !m.cells.length) return;
313 const seq = ['--seq-1', '--seq-2', '--seq-3', '--seq-4', '--seq-5', '--seq-6'].map(v => `var(${v})`);
314 legend('lg-map', [[seq[1], 'Fewer stops'], [seq[5], 'More stops'], [S2, 'ALPR camera']]);
315 // bounds must cover the cameras too, or they render outside the frame
316 const lats = m.cells.map(c => c[0]).concat(m.cameras.map(c => c[0]));
317 const lons = m.cells.map(c => c[1]).concat(m.cameras.map(c => c[1]));
318 const lat0 = Math.min(...lats) - m.cell, lat1 = Math.max(...lats) + m.cell * 2;
319 const lon0 = Math.min(...lons) - m.cell, lon1 = Math.max(...lons) + m.cell * 2;
320 const k = Math.cos((lat0 + lat1) / 2 * Math.PI / 180);
321 const W = 900, H = Math.round(W * (lat1 - lat0) / ((lon1 - lon0) * k));
322 const x = lon => (lon - lon0) / (lon1 - lon0) * W;
323 const y = lat => H - (lat - lat0) / (lat1 - lat0) * H;
324 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
325 'aria-label': 'Map of vehicle stop density with ALPR camera locations' });
326 const clip = el('clipPath', { id: 'mapclip' });
327 clip.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, rx: 6 }));
328 svg.appendChild(clip);
329 svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'var(--surface-0)', rx: 6 }));
330 const plot = el('g', { 'clip-path': 'url(#mapclip)' });
331 svg.appendChild(plot);
332
333 const counts = m.cells.map(c => c[2]).sort((a, b) => a - b);
334 const q = p => counts[Math.floor(p * (counts.length - 1))];
335 const breaks = [q(.4), q(.65), q(.82), q(.93), q(.98)];
336 const colour = n => seq[breaks.filter(b => n > b).length];
337 const cw = x(lon0 + m.cell) - x(lon0), ch = y(lat0) - y(lat0 + m.cell);
338 m.cells.forEach(([la, lo, n]) => {
339 const r = el('rect', { x: x(lo), y: y(la + m.cell), width: Math.max(cw - 1, 1),
340 height: Math.max(ch - 1, 1), fill: colour(n), rx: 1 });
341 plot.appendChild(r);
342 hoverable(r, `${fmt(n)} vehicle stops in this cell`);
343 });
344 m.cameras.forEach(([la, lo]) => {
345 const c = el('circle', { cx: x(lo), cy: y(la), r: 4.5, fill: S2,
346 stroke: 'var(--surface-0)', 'stroke-width': 2 });
347 plot.appendChild(c);
348 hoverable(c, 'ALPR camera<br>' + la.toFixed(4) + ', ' + lo.toFixed(4));
349 });
350 [['Omaha', 41.257, -95.995], ['Council Bluffs', 41.262, -95.861],
351 ['Bellevue', 41.137, -95.914], ['Papillion', 41.155, -96.043],
352 ['La Vista', 41.183, -96.031], ['Ralston', 41.205, -96.043]].forEach(([n, la, lo]) => {
353 if (la < lat0 || la > lat1 || lo < lon0 || lo > lon1) return;
354 const t = el('text', { x: x(lo), y: y(la), 'text-anchor': 'middle', class: 'cat' });
355 t.setAttribute('stroke', 'var(--surface-0)'); t.setAttribute('stroke-width', '3');
356 t.setAttribute('paint-order', 'stroke'); t.textContent = n;
357 plot.appendChild(t);
358 });
359 document.getElementById('c-map').appendChild(svg);
360})();
361
362/* ---- search audit ---- */
363(function () {
364 const s = DATA.searches, host = document.getElementById('searches');
365 if (!s) { host.innerHTML = '<p>No search audit has been collected yet.</p>'; return; }
366 const missing = s.searches - s.with_reason;
367 host.innerHTML =
368 `<p>Council Bluffs PD publishes every search run against its camera network for the ` +
369 `trailing 30 days. Between ${s.first} and ${s.last} there were <b>${fmt(s.searches)}</b>. ` +
370 `Its own access policy states that all system access requires a valid reason.</p>` +
371 `<div class="card">` +
372 `<div class="legend"><span><i style="background:${S2}"></i>Search recorded a reason</span>` +
373 `<span><i style="background:var(--grid)"></i>No reason recorded</span></div>` +
374 `<div id="c-reason"></div>` +
375 `<p class="note" style="margin:.7rem 0 0">${fmt(s.with_reason)} of ${fmt(s.searches)} searches ` +
376 `carried any reason at all. The reasons that exist are free text, often a single word. ` +
377 `The user id is redacted by Flock before publication, so no search here can be attributed ` +
378 `to a person.</p>` +
379 `</div>` +
380 `<p style="margin-top:1.4rem">Each search reaches across a sharing network of other agencies' ` +
381 `cameras. The median search touched <b>${fmt(s.median_networks)}</b> networks; the largest ` +
382 `touched <b>${fmt(s.max_networks)}</b>.</p>` +
383 `<div class="card"><div id="c-net"></div></div>` +
384 `<details><summary>Most common stated reasons</summary><div id="t-reasons"></div></details>`;
385
386 const W = 720, H = 46;
387 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
388 'aria-label': 'Share of searches recording a reason' });
389 const w1 = W * s.reason_rate;
390 svg.appendChild(el('rect', { class: 'bar-track', x: 0, y: 0, width: W, height: 26, rx: 4 }));
391 svg.appendChild(el('rect', { x: 0, y: 0, width: w1, height: 26, rx: 4, fill: S2 }));
392 const l1 = el('text', { x: 0, y: H - 4, class: 'val' });
393 l1.textContent = `${fmt(s.with_reason)} with a reason (${pct(s.reason_rate)})`;
394 svg.appendChild(l1);
395 const l2 = el('text', { x: W, y: H - 4, 'text-anchor': 'end', class: 'val' });
396 l2.textContent = `${fmt(missing)} without`;
397 svg.appendChild(l2);
398 document.getElementById('c-reason').appendChild(svg);
399
400 // keep empty bins: dropping them would compress the axis and hide the gap
401 const h = s.histogram;
402 const NW = 720, NH = 210, padL = 8, padB = 40;
403 const nmax = Math.max(...h.map(b => b.n));
404 const bw = (NW - padL) / h.length;
405 const nsvg = el('svg', { viewBox: `0 0 ${NW} ${NH}`, role: 'img',
406 'aria-label': 'Searches by number of camera networks reached' });
407 h.forEach((b, i) => {
408 const bh = (b.n / nmax) * (NH - padB - 16);
409 const bx = padL + i * bw, by = NH - padB - bh;
410 const r = el('rect', { x: bx + 2, y: by, width: bw - 6, height: bh, rx: 4, fill: S1 });
411 nsvg.appendChild(r);
412 hoverable(r, `<b>${fmt(b.n)} searches</b><br>reached ${fmt(b.lo + 1)}–${fmt(b.hi)} networks`);
413 const v = el('text', { x: bx + bw / 2 - 2, y: by - 6, 'text-anchor': 'middle', class: 'val' });
414 v.textContent = b.n ? fmt(b.n) : ''; nsvg.appendChild(v);
415 const t = el('text', { x: bx + bw / 2 - 2, y: NH - padB + 18, 'text-anchor': 'middle' });
416 t.textContent = b.hi >= 10000 ? '2500+' : fmt(b.hi); nsvg.appendChild(t);
417 });
418 const nx = el('text', { x: NW / 2, y: NH - 8, 'text-anchor': 'middle' });
419 nx.textContent = 'camera networks reached by the search (upper bound of each band)';
420 nsvg.appendChild(nx);
421 document.getElementById('c-net').appendChild(nsvg);
422 table('t-reasons', ['Stated reason', 'Searches'], s.reasons.map(r => [r.reason, r.n]));
423})();
424
425/* ---- weekly small multiples ---- */
426(function () {
427 const w = DATA.weekly, host = document.getElementById('c-weekly');
428 Object.keys(w).sort((a, b) => {
429 const sa = w[a].counts.reduce((x, y) => x + y, 0), sb = w[b].counts.reduce((x, y) => x + y, 0);
430 return sb - sa;
431 }).forEach(agency => {
432 const g = w[agency];
433 if (g.counts.length < 2) return;
434 const W = 260, H = 74;
435 const max = Math.max(...g.counts);
436 const x = i => (i / (g.counts.length - 1)) * W;
437 const y = v => H - 4 - (v / max) * (H - 12);
438 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
439 'aria-label': `Weekly incidents reported by ${agency}` });
440 svg.appendChild(el('path', {
441 d: g.counts.map((v, i) => `${i ? 'L' : 'M'}${x(i)},${y(v)}`).join(' '),
442 fill: 'none', stroke: S1, 'stroke-width': 2, 'stroke-linejoin': 'round' }));
443 const hit = el('rect', { class: 'hit', x: 0, y: 0, width: W, height: H });
444 svg.appendChild(hit);
445 hit.addEventListener('mousemove', e => {
446 const box = svg.getBoundingClientRect();
447 const i = Math.round((e.clientX - box.left) / box.width * (g.counts.length - 1));
448 const j = Math.max(0, Math.min(g.counts.length - 1, i));
449 showTip(e, `<b>${agency}</b><br>week of ${g.weeks[j]}<br>${fmt(g.counts[j])} incidents`);
450 });
451 hit.addEventListener('mouseleave', hideTip);
452 const box = document.createElement('div');
453 box.className = 'sm card';
454 box.innerHTML = `<h3>${agency}</h3><div class="sub">${g.weeks[0]} → ` +
455 `${g.weeks[g.weeks.length - 1]} &middot; peak ${fmt(max)}/wk</div>`;
456 box.appendChild(svg);
457 host.appendChild(box);
458 });
459})();
460
461/* ---- coverage table ---- */
462table('t-coverage', ['Agency', 'Incidents', 'Vehicle stops', 'From', 'To'],
463 DATA.agencies.map(a => [a.agency, fmt(a.incidents),
464 a.stops ? fmt(a.stops) : '—', a.first, a.last]));
465</script>
466</body>
467</html>