krz/omaha-metro-blotter
clone: git clone https://gitbay.org/krz/omaha-metro-blotter.git
main: site_template.html · raw
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 --outline: #c9c6bd;
19 --series-1: #2a78d6;
20 --series-2: #eb6834;
21 --seq-1: #cde2fb; --seq-2: #9ec5f4; --seq-3: #6da7ec;
22 --seq-4: #3987e5; --seq-5: #256abf; --seq-6: #0d366b;
23}
24@media (prefers-color-scheme: dark) {
25 :root {
26 color-scheme: dark;
27 --surface-0: #121211;
28 --surface-1: #1a1a19;
29 --border: #34342f;
30 --text-primary: #ffffff;
31 --text-secondary: #c3c2b7;
32 --text-muted: #96958c;
33 --grid: #2c2c28;
34 --outline: #45443e;
35 --series-1: #3987e5;
36 --series-2: #d95926;
37 --seq-1: #104281; --seq-2: #184f95; --seq-3: #256abf;
38 --seq-4: #2a78d6; --seq-5: #5598e7; --seq-6: #9ec5f4;
39 }
40}
41* { box-sizing: border-box; }
42body {
43 margin: 0; background: var(--surface-0); color: var(--text-primary);
44 font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
45 -webkit-font-smoothing: antialiased;
46}
47.wrap { max-width: 1080px; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
48h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .5rem; letter-spacing: -.02em; }
49h2 { font-size: 1.15rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
50p { margin: 0 0 1rem; color: var(--text-secondary); max-width: 68ch; }
51.lede { font-size: 1.05rem; }
52.stamp { color: var(--text-muted); font-size: .85rem; }
53section { margin: 3rem 0 0; }
54.note { font-size: .875rem; color: var(--text-muted); max-width: 68ch; }
55.card {
56 background: var(--surface-1); border: 1px solid var(--border);
57 border-radius: 10px; padding: 1.1rem 1.2rem; margin-top: .9rem;
58}
59.tiles { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); margin-top: 1.5rem; }
60.tile { background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; padding: .85rem 1rem; }
61.tile b { display: block; font-size: 1.65rem; font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
62.tile span { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .1rem; }
63.legend { display: flex; flex-wrap: wrap; gap: 1rem; font-size: .85rem; color: var(--text-secondary); margin: .1rem 0 .8rem; }
64.legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-right: .4rem; vertical-align: -1px; }
65svg { display: block; width: 100%; height: auto; overflow: visible; }
66svg text { fill: var(--text-secondary); font-size: 11px; }
67svg .axis-line { stroke: var(--border); stroke-width: 1; }
68svg .grid-line { stroke: var(--grid); stroke-width: 1; }
69svg .val { fill: var(--text-primary); font-size: 11px; font-variant-numeric: tabular-nums; }
70svg .cat { fill: var(--text-primary); font-size: 12px; }
71.small-mult { display: grid; gap: .9rem; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
72.sm h3 { font-size: .85rem; margin: 0 0 .1rem; font-weight: 600; }
73.sm .sub { font-size: .75rem; color: var(--text-muted); margin-bottom: .2rem; font-variant-numeric: tabular-nums; }
74table { border-collapse: collapse; width: 100%; font-size: .85rem; font-variant-numeric: tabular-nums; }
75th, td { text-align: right; padding: .35rem .6rem; border-bottom: 1px solid var(--border); }
76th:first-child, td:first-child { text-align: left; }
77th { color: var(--text-muted); font-weight: 600; }
78details { margin-top: .8rem; }
79summary { cursor: pointer; font-size: .85rem; color: var(--text-secondary); }
80#tip {
81 position: fixed; pointer-events: none; opacity: 0; transition: opacity .1s;
82 background: var(--surface-1); border: 1px solid var(--border); border-radius: 7px;
83 padding: .4rem .6rem; font-size: .8rem; color: var(--text-primary);
84 box-shadow: 0 4px 14px rgba(0,0,0,.16); z-index: 9; max-width: 260px;
85 font-variant-numeric: tabular-nums;
86}
87.bar-track { fill: var(--grid); }
88.hit { fill: transparent; }
89footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: .85rem; color: var(--text-muted); }
90footer a { color: var(--text-secondary); }
91</style>
92</head>
93<body>
94<div id="tip" role="status" aria-live="polite"></div>
95<div class="wrap">
96<h1>Omaha Metro Police Activity</h1>
97<p class="lede" id="lede"></p>
98<p class="stamp" id="stamp"></p>
99
100<div class="tiles" id="tiles"></div>
101
102<section>
103 <h2>Vehicle stop outcomes</h2>
104 <p>What share of each agency's officer-initiated vehicle stops ended in a citation, and what share in an arrest.</p>
105 <div class="card">
106 <div class="legend" id="lg-out"></div>
107 <div id="c-outcomes"></div>
108 </div>
109 <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 “Handled by Officer.” 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>
110 <details><summary>Show the numbers</summary><div id="t-outcomes"></div></details>
111</section>
112
113<section>
114 <h2>Distance to the nearest ALPR camera</h2>
115 <p>Where vehicle stops happen relative to automated licence plate readers, against every other call handled by <em>the same agencies</em> as a baseline. Both lines are shares of their own series, so the gap between them is what matters.</p>
116 <div class="card">
117 <div class="legend" id="lg-prox"></div>
118 <div id="c-prox"></div>
119 </div>
120 <p class="note"><strong>The two lines nearly overlap, and that is the finding.</strong> Stops sit marginally closer to cameras than the same agencies' other calls — 14.7% against 12.4% within 200 m — but the median distance is 805 m for stops and 780 m for everything else. There is no meaningful separation here.</p>
121 <p class="note">Comparing stops against <em>every</em> incident in the archive instead produces a much larger gap, 2.6× within 200 m. That gap is an artifact: most of the archive is Omaha, a city that reports no stops at all, so the comparison was measuring the difference between two geographies rather than between stops and other calls. Restricting the baseline to the five agencies that actually report stops removes it.</p>
122 <p class="note">All three metro Flock transparency portals list <em>traffic enforcement</em> under prohibited uses. On this measure, at this resolution, nothing here contradicts that. It is a weak instrument — cameras and enforcement both concentrate on arterial roads, and camera locations are volunteer-mapped and incomplete — so it cannot clear an agency either.</p>
123 <details><summary>Show the numbers</summary><div id="t-prox"></div></details>
124</section>
125
126<section>
127 <h2>Where stops and cameras sit</h2>
128 <p>Vehicle stops aggregated onto a grid, with every mapped ALPR camera drawn on top.</p>
129 <div class="card">
130 <div class="legend" id="lg-map"></div>
131 <div id="c-map"></div>
132 </div>
133 <p class="note">Outlines are city limits from Douglas County and Sarpy County GIS, with the Douglas County line dashed. Council Bluffs sits in Pottawattamie County, Iowa, which publishes neither, so it has no outline. Omaha is blank because Omaha PD publishes no stop data — 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>
134</section>
135
136<section>
137 <h2>ALPR searches</h2>
138 <div id="searches"></div>
139</section>
140
141<section>
142 <h2>Reported volume by agency</h2>
143 <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>
144 <div class="small-mult" id="c-weekly"></div>
145</section>
146
147<section>
148 <h2>What is and is not here</h2>
149 <div class="card"><div id="t-coverage"></div></div>
150 <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 “crime” across agencies — only what each agency itself published.</p>
151</section>
152
153<footer>
154 <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>
155 <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-metro-blotter">github.com/krazywarez/omaha-metro-blotter</a>.</p>
156</footer>
157</div>
158
159<script>
160const DATA = /*DATA*/null;
161const S1 = 'var(--series-1)', S2 = 'var(--series-2)';
162const fmt = n => n.toLocaleString('en-US');
163const pct = (v, d = 1) => (v * 100).toFixed(d) + '%';
164const tip = document.getElementById('tip');
165
166function showTip(evt, html) {
167 tip.innerHTML = html;
168 tip.style.opacity = 1;
169 const pad = 14, r = tip.getBoundingClientRect();
170 let x = evt.clientX + pad, y = evt.clientY + pad;
171 if (x + r.width > innerWidth - 8) x = evt.clientX - r.width - pad;
172 if (y + r.height > innerHeight - 8) y = evt.clientY - r.height - pad;
173 tip.style.left = x + 'px'; tip.style.top = y + 'px';
174}
175const hideTip = () => { tip.style.opacity = 0; };
176function hoverable(el, html) {
177 el.addEventListener('mousemove', e => showTip(e, html));
178 el.addEventListener('mouseleave', hideTip);
179}
180const el = (tag, attrs = {}, kids = []) => {
181 const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
182 for (const k in attrs) n.setAttribute(k, attrs[k]);
183 kids.forEach(c => n.appendChild(c));
184 return n;
185};
186function legend(id, items) {
187 document.getElementById(id).innerHTML = items
188 .map(([c, l]) => `<span><i style="background:${c}"></i>${l}</span>`).join('');
189}
190function table(id, cols, rows) {
191 document.getElementById(id).innerHTML =
192 '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') +
193 '</tr></thead><tbody>' + rows.map(r => '<tr>' +
194 r.map(c => `<td>${c}</td>`).join('') + '</tr>').join('') + '</tbody></table>';
195}
196
197/* ---- tiles ---- */
198(function () {
199 const t = DATA.totals;
200 document.getElementById('lede').textContent =
201 `${fmt(t.incidents)} incidents and ${fmt(t.stops)} officer-initiated vehicle stops from six law` +
202 ` enforcement agencies, against ${fmt(t.cameras)} mapped licence plate readers and` +
203 ` ${fmt(t.searches)} published searches of one agency's camera network.`;
204 document.getElementById('stamp').textContent = 'Rebuilt ' + t.built + '.';
205 const tiles = [
206 [fmt(t.incidents), 'Incidents'], [fmt(t.stops), 'Vehicle stops'],
207 [fmt(t.cameras), 'ALPR cameras'], [fmt(t.searches), 'Published searches'],
208 [fmt(t.raw), 'Raw payloads kept'],
209 ];
210 document.getElementById('tiles').innerHTML = tiles
211 .map(([v, l]) => `<div class="tile"><b>${v}</b><span>${l}</span></div>`).join('');
212})();
213
214/* ---- stop outcomes: grouped horizontal bars ---- */
215(function () {
216 const d = DATA.outcomes;
217 if (!d.length) return;
218 legend('lg-out', [[S1, 'Cited'], [S2, 'Arrested']]);
219 const W = 720, rowH = 52, padL = 150, padR = 64, top = 8;
220 const H = top + d.length * rowH + 26;
221 const max = Math.max(...d.map(r => r.cited)) * 1.12;
222 const x = v => padL + (v / max) * (W - padL - padR);
223 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
224 'aria-label': 'Citation and arrest rate on vehicle stops, by agency' });
225
226 [0, .2, .4, .6].forEach(g => {
227 if (g > max) return;
228 svg.appendChild(el('line', { class: 'grid-line', x1: x(g), x2: x(g), y1: top, y2: top + d.length * rowH }));
229 const tx = el('text', { x: x(g), y: H - 8, 'text-anchor': 'middle' });
230 tx.textContent = pct(g, 0); svg.appendChild(tx);
231 });
232
233 d.forEach((r, i) => {
234 const yTop = top + i * rowH;
235 const name = el('text', { x: padL - 12, y: yTop + 26, 'text-anchor': 'end', class: 'cat' });
236 name.textContent = r.agency; svg.appendChild(name);
237 // 2px surface gap between the paired bars
238 [['cited', S1, yTop + 8, 15], ['arrested', S2, yTop + 25, 15]].forEach(([k, col, y, h]) => {
239 const w = Math.max(x(r[k]) - padL, 0);
240 const bar = el('rect', { x: padL, y, width: w, height: h, rx: 4, fill: col });
241 svg.appendChild(bar);
242 const lab = el('text', { x: padL + w + 8, y: y + h - 3, class: 'val' });
243 lab.textContent = pct(r[k], k === 'arrested' ? 2 : 1);
244 svg.appendChild(lab);
245 const hit = el('rect', { class: 'hit', x: padL, y: y - 2, width: W - padL, height: h + 4 });
246 svg.appendChild(hit);
247 hoverable(hit, `<b>${r.agency}</b><br>${k === 'cited' ? 'Cited' : 'Arrested'}: ` +
248 `${pct(r[k], 2)} of ${fmt(r.stops)} stops`);
249 });
250 });
251 document.getElementById('c-outcomes').appendChild(svg);
252 table('t-outcomes', ['Agency', 'Stops', 'Cited', 'Arrested'],
253 d.map(r => [r.agency, fmt(r.stops), pct(r.cited), pct(r.arrested, 2)]));
254})();
255
256/* ---- distance to nearest camera: two lines + crosshair ---- */
257(function () {
258 const d = DATA.proximity;
259 if (!d.length) return;
260 legend('lg-prox', [[S2, 'Vehicle stops'], [S1, 'All other incidents']]);
261 const W = 720, H = 300, padL = 52, padR = 96, padT = 12, padB = 44;
262 const maxY = Math.max(...d.map(r => Math.max(r.stops, r.other))) * 1.1;
263 const maxX = Math.max(...d.map(r => r.m));
264 const x = v => padL + (v / maxX) * (W - padL - padR);
265 const y = v => H - padB - (v / maxY) * (H - padT - padB);
266 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
267 'aria-label': 'Share of incidents by distance to the nearest ALPR camera' });
268
269 for (let g = 0; g <= maxY; g += 0.04) {
270 svg.appendChild(el('line', { class: 'grid-line', x1: padL, x2: W - padR, y1: y(g), y2: y(g) }));
271 const t = el('text', { x: padL - 10, y: y(g) + 4, 'text-anchor': 'end' });
272 t.textContent = pct(g, 0); svg.appendChild(t);
273 }
274 d.forEach(r => {
275 const t = el('text', { x: x(r.m), y: H - padB + 18, 'text-anchor': 'middle' });
276 t.textContent = r.m; svg.appendChild(t);
277 });
278 const ax = el('text', { x: (padL + W - padR) / 2, y: H - 6, 'text-anchor': 'middle' });
279 ax.textContent = 'metres to the nearest camera'; svg.appendChild(ax);
280
281 const path = (key, col) => {
282 const dstr = d.map((r, i) => `${i ? 'L' : 'M'}${x(r.m)},${y(r[key])}`).join(' ');
283 svg.appendChild(el('path', { d: dstr, fill: 'none', stroke: col,
284 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
285 d.forEach(r => svg.appendChild(el('circle', { cx: x(r.m), cy: y(r[key]), r: 4,
286 fill: col, stroke: 'var(--surface-1)', 'stroke-width': 2 })));
287 const last = d[d.length - 1];
288 const lab = el('text', { x: x(last.m) + 10, y: y(last[key]) + 4, class: 'val' });
289 lab.textContent = key === 'stops' ? 'stops' : 'other'; svg.appendChild(lab);
290 };
291 path('other', S1); path('stops', S2);
292
293 const cross = el('line', { class: 'axis-line', y1: padT, y2: H - padB, opacity: 0 });
294 svg.appendChild(cross);
295 const band = el('rect', { class: 'hit', x: padL, y: padT, width: W - padL - padR, height: H - padT - padB });
296 svg.appendChild(band);
297 band.addEventListener('mousemove', e => {
298 const box = svg.getBoundingClientRect();
299 const vx = (e.clientX - box.left) / box.width * W;
300 let best = d[0];
301 d.forEach(r => { if (Math.abs(x(r.m) - vx) < Math.abs(x(best.m) - vx)) best = r; });
302 cross.setAttribute('x1', x(best.m)); cross.setAttribute('x2', x(best.m));
303 cross.setAttribute('opacity', 1);
304 showTip(e, `<b>${best.m}–${best.m + (d[1] ? d[1].m - d[0].m : 200)} m</b><br>` +
305 `Vehicle stops: ${pct(best.stops)}<br>All other: ${pct(best.other)}`);
306 });
307 band.addEventListener('mouseleave', () => { cross.setAttribute('opacity', 0); hideTip(); });
308 document.getElementById('c-prox').appendChild(svg);
309 table('t-prox', ['Distance (m)', 'Vehicle stops', 'All other incidents'],
310 d.map(r => [r.m, pct(r.stops, 2), pct(r.other, 2)]));
311})();
312
313/* ---- map: stop-density grid + cameras ---- */
314(function () {
315 const m = DATA.map;
316 if (!m || !m.cells.length) return;
317 const seq = ['--seq-1', '--seq-2', '--seq-3', '--seq-4', '--seq-5', '--seq-6'].map(v => `var(${v})`);
318 legend('lg-map', [[seq[1], 'Fewer stops'], [seq[5], 'More stops'], [S2, 'ALPR camera'],
319 ['var(--outline)', 'City limits, Douglas County line']]);
320 // bounds must cover the cameras too, or they render outside the frame
321 const lats = m.cells.map(c => c[0]).concat(m.cameras.map(c => c[0]));
322 const lons = m.cells.map(c => c[1]).concat(m.cameras.map(c => c[1]));
323 const lat0 = Math.min(...lats) - m.cell, lat1 = Math.max(...lats) + m.cell * 2;
324 const lon0 = Math.min(...lons) - m.cell, lon1 = Math.max(...lons) + m.cell * 2;
325 const k = Math.cos((lat0 + lat1) / 2 * Math.PI / 180);
326 const W = 900, H = Math.round(W * (lat1 - lat0) / ((lon1 - lon0) * k));
327 const x = lon => (lon - lon0) / (lon1 - lon0) * W;
328 const y = lat => H - (lat - lat0) / (lat1 - lat0) * H;
329 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
330 'aria-label': 'Map of vehicle stop density with ALPR camera locations' });
331 const clip = el('clipPath', { id: 'mapclip' });
332 clip.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, rx: 6 }));
333 svg.appendChild(clip);
334 svg.appendChild(el('rect', { x: 0, y: 0, width: W, height: H, fill: 'var(--surface-0)', rx: 6 }));
335 const plot = el('g', { 'clip-path': 'url(#mapclip)' });
336 svg.appendChild(plot);
337
338 const counts = m.cells.map(c => c[2]).sort((a, b) => a - b);
339 const q = p => counts[Math.floor(p * (counts.length - 1))];
340 const breaks = [q(.4), q(.65), q(.82), q(.93), q(.98)];
341 const colour = n => seq[breaks.filter(b => n > b).length];
342 // outlines first, so the data sits on top of them
343 (m.boundaries || []).forEach(b => {
344 const d = b.rings.map(r => r.map((pt, i) =>
345 `${i ? 'L' : 'M'}${x(pt[0]).toFixed(1)},${y(pt[1]).toFixed(1)}`).join(' ') + 'Z').join(' ');
346 plot.appendChild(el('path', { d, fill: 'none', stroke: 'var(--outline)',
347 'stroke-width': b.kind === 'county' ? 1.6 : 1,
348 'stroke-linejoin': 'round',
349 'stroke-dasharray': b.kind === 'county' ? '6 4' : 'none' }));
350 });
351
352 const cw = x(lon0 + m.cell) - x(lon0), ch = y(lat0) - y(lat0 + m.cell);
353 m.cells.forEach(([la, lo, n]) => {
354 const r = el('rect', { x: x(lo), y: y(la + m.cell), width: Math.max(cw - 1, 1),
355 height: Math.max(ch - 1, 1), fill: colour(n), rx: 1 });
356 plot.appendChild(r);
357 hoverable(r, `${fmt(n)} vehicle stops in this cell`);
358 });
359 m.cameras.forEach(([la, lo]) => {
360 const c = el('circle', { cx: x(lo), cy: y(la), r: 4.5, fill: S2,
361 stroke: 'var(--surface-0)', 'stroke-width': 2 });
362 plot.appendChild(c);
363 hoverable(c, 'ALPR camera<br>' + la.toFixed(4) + ', ' + lo.toFixed(4));
364 });
365 [['Omaha', 41.257, -95.995], ['Council Bluffs', 41.262, -95.861],
366 ['Bellevue', 41.137, -95.914], ['Papillion', 41.155, -96.043],
367 ['La Vista', 41.183, -96.031], ['Ralston', 41.205, -96.043]].forEach(([n, la, lo]) => {
368 if (la < lat0 || la > lat1 || lo < lon0 || lo > lon1) return;
369 const t = el('text', { x: x(lo), y: y(la), 'text-anchor': 'middle', class: 'cat' });
370 t.setAttribute('stroke', 'var(--surface-0)'); t.setAttribute('stroke-width', '3');
371 t.setAttribute('paint-order', 'stroke'); t.textContent = n;
372 plot.appendChild(t);
373 });
374 document.getElementById('c-map').appendChild(svg);
375})();
376
377/* ---- search audit ---- */
378(function () {
379 const s = DATA.searches, host = document.getElementById('searches');
380 if (!s) { host.innerHTML = '<p>No search audit has been collected yet.</p>'; return; }
381 const missing = s.searches - s.with_reason;
382 host.innerHTML =
383 `<p>Council Bluffs PD publishes every search run against its camera network for the ` +
384 `trailing 30 days. Between ${s.first} and ${s.last} there were <b>${fmt(s.searches)}</b>. ` +
385 `Its own access policy states that all system access requires a valid reason.</p>` +
386 `<div class="card">` +
387 `<div class="legend"><span><i style="background:${S2}"></i>Search recorded a reason</span>` +
388 `<span><i style="background:var(--grid)"></i>No reason recorded</span></div>` +
389 `<div id="c-reason"></div>` +
390 `<p class="note" style="margin:.7rem 0 0">${fmt(s.with_reason)} of ${fmt(s.searches)} searches ` +
391 `carried any reason at all. The reasons that exist are free text, often a single word. ` +
392 `The user id is redacted by Flock before publication, so no search here can be attributed ` +
393 `to a person.</p>` +
394 `</div>` +
395 `<p style="margin-top:1.4rem">Each search reaches across a sharing network of other agencies' ` +
396 `cameras. The median search touched <b>${fmt(s.median_networks)}</b> networks; the largest ` +
397 `touched <b>${fmt(s.max_networks)}</b>.</p>` +
398 `<div class="card"><div id="c-net"></div></div>` +
399 `<details><summary>Most common stated reasons</summary><div id="t-reasons"></div></details>`;
400
401 const W = 720, H = 46;
402 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
403 'aria-label': 'Share of searches recording a reason' });
404 const w1 = W * s.reason_rate;
405 svg.appendChild(el('rect', { class: 'bar-track', x: 0, y: 0, width: W, height: 26, rx: 4 }));
406 svg.appendChild(el('rect', { x: 0, y: 0, width: w1, height: 26, rx: 4, fill: S2 }));
407 const l1 = el('text', { x: 0, y: H - 4, class: 'val' });
408 l1.textContent = `${fmt(s.with_reason)} with a reason (${pct(s.reason_rate)})`;
409 svg.appendChild(l1);
410 const l2 = el('text', { x: W, y: H - 4, 'text-anchor': 'end', class: 'val' });
411 l2.textContent = `${fmt(missing)} without`;
412 svg.appendChild(l2);
413 document.getElementById('c-reason').appendChild(svg);
414
415 // keep empty bins: dropping them would compress the axis and hide the gap
416 const h = s.histogram;
417 const NW = 720, NH = 210, padL = 8, padB = 40;
418 const nmax = Math.max(...h.map(b => b.n));
419 const bw = (NW - padL) / h.length;
420 const nsvg = el('svg', { viewBox: `0 0 ${NW} ${NH}`, role: 'img',
421 'aria-label': 'Searches by number of camera networks reached' });
422 h.forEach((b, i) => {
423 const bh = (b.n / nmax) * (NH - padB - 16);
424 const bx = padL + i * bw, by = NH - padB - bh;
425 const r = el('rect', { x: bx + 2, y: by, width: bw - 6, height: bh, rx: 4, fill: S1 });
426 nsvg.appendChild(r);
427 hoverable(r, `<b>${fmt(b.n)} searches</b><br>reached ${fmt(b.lo + 1)}–${fmt(b.hi)} networks`);
428 const v = el('text', { x: bx + bw / 2 - 2, y: by - 6, 'text-anchor': 'middle', class: 'val' });
429 v.textContent = b.n ? fmt(b.n) : ''; nsvg.appendChild(v);
430 const t = el('text', { x: bx + bw / 2 - 2, y: NH - padB + 18, 'text-anchor': 'middle' });
431 t.textContent = b.hi >= 10000 ? '2500+' : fmt(b.hi); nsvg.appendChild(t);
432 });
433 const nx = el('text', { x: NW / 2, y: NH - 8, 'text-anchor': 'middle' });
434 nx.textContent = 'camera networks reached by the search (upper bound of each band)';
435 nsvg.appendChild(nx);
436 document.getElementById('c-net').appendChild(nsvg);
437 table('t-reasons', ['Stated reason', 'Searches'], s.reasons.map(r => [r.reason, r.n]));
438})();
439
440/* ---- weekly small multiples ---- */
441(function () {
442 const w = DATA.weekly, host = document.getElementById('c-weekly');
443 Object.keys(w).sort((a, b) => {
444 const sa = w[a].counts.reduce((x, y) => x + y, 0), sb = w[b].counts.reduce((x, y) => x + y, 0);
445 return sb - sa;
446 }).forEach(agency => {
447 const g = w[agency];
448 if (g.counts.length < 2) return;
449 const W = 260, H = 74;
450 const max = Math.max(...g.counts);
451 const x = i => (i / (g.counts.length - 1)) * W;
452 const y = v => H - 4 - (v / max) * (H - 12);
453 const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img',
454 'aria-label': `Weekly incidents reported by ${agency}` });
455 svg.appendChild(el('path', {
456 d: g.counts.map((v, i) => `${i ? 'L' : 'M'}${x(i)},${y(v)}`).join(' '),
457 fill: 'none', stroke: S1, 'stroke-width': 2, 'stroke-linejoin': 'round' }));
458 const hit = el('rect', { class: 'hit', x: 0, y: 0, width: W, height: H });
459 svg.appendChild(hit);
460 hit.addEventListener('mousemove', e => {
461 const box = svg.getBoundingClientRect();
462 const i = Math.round((e.clientX - box.left) / box.width * (g.counts.length - 1));
463 const j = Math.max(0, Math.min(g.counts.length - 1, i));
464 showTip(e, `<b>${agency}</b><br>week of ${g.weeks[j]}<br>${fmt(g.counts[j])} incidents`);
465 });
466 hit.addEventListener('mouseleave', hideTip);
467 const box = document.createElement('div');
468 box.className = 'sm card';
469 box.innerHTML = `<h3>${agency}</h3><div class="sub">${g.weeks[0]} → ` +
470 `${g.weeks[g.weeks.length - 1]} · peak ${fmt(max)}/wk</div>`;
471 box.appendChild(svg);
472 host.appendChild(box);
473 });
474})();
475
476/* ---- coverage table ---- */
477table('t-coverage', ['Agency', 'Incidents', 'Vehicle stops', 'From', 'To'],
478 DATA.agencies.map(a => [a.agency, fmt(a.incidents),
479 a.stops ? fmt(a.stops) : '—', a.first, a.last]));
480</script>
481</body>
482</html>