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 &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>
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 &mdash; 14.7% against 12.4% within 200&nbsp;m &mdash; but the median distance is 805&nbsp;m for stops and 780&nbsp;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&times; within 200&nbsp;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 &mdash; cameras and enforcement both concentrate on arterial roads, and camera locations are volunteer-mapped and incomplete &mdash; 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 &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>
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 &ldquo;crime&rdquo; across agencies &mdash; 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]} &middot; 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>