| @@ -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; } |
| |
40 | body { |
| |
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; } |
| |
46 | h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .5rem; letter-spacing: -.02em; } |
| |
47 | h2 { font-size: 1.15rem; margin: 0 0 .25rem; letter-spacing: -.01em; } |
| |
48 | p { 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; } |
| |
51 | section { 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; } |
| |
63 | svg { display: block; width: 100%; height: auto; overflow: visible; } |
| |
64 | svg text { fill: var(--text-secondary); font-size: 11px; } |
| |
65 | svg .axis-line { stroke: var(--border); stroke-width: 1; } |
| |
66 | svg .grid-line { stroke: var(--grid); stroke-width: 1; } |
| |
67 | svg .val { fill: var(--text-primary); font-size: 11px; font-variant-numeric: tabular-nums; } |
| |
68 | svg .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; } |
| |
72 | table { border-collapse: collapse; width: 100%; font-size: .85rem; font-variant-numeric: tabular-nums; } |
| |
73 | th, td { text-align: right; padding: .35rem .6rem; border-bottom: 1px solid var(--border); } |
| |
74 | th:first-child, td:first-child { text-align: left; } |
| |
75 | th { color: var(--text-muted); font-weight: 600; } |
| |
76 | details { margin-top: .8rem; } |
| |
77 | summary { 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; } |
| |
87 | footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: .85rem; color: var(--text-muted); } |
| |
88 | footer 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 “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> |
| |
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 — 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 “crime” across agencies — 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> |
| |
156 | const DATA = /*DATA*/null; |
| |
157 | const S1 = 'var(--series-1)', S2 = 'var(--series-2)'; |
| |
158 | const fmt = n => n.toLocaleString('en-US'); |
| |
159 | const pct = (v, d = 1) => (v * 100).toFixed(d) + '%'; |
| |
160 | const tip = document.getElementById('tip'); |
| |
161 | |
| |
162 | function 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 | } |
| |
171 | const hideTip = () => { tip.style.opacity = 0; }; |
| |
172 | function hoverable(el, html) { |
| |
173 | el.addEventListener('mousemove', e => showTip(e, html)); |
| |
174 | el.addEventListener('mouseleave', hideTip); |
| |
175 | } |
| |
176 | const 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 | }; |
| |
182 | function legend(id, items) { |
| |
183 | document.getElementById(id).innerHTML = items |
| |
184 | .map(([c, l]) => `<span><i style="background:${c}"></i>${l}</span>`).join(''); |
| |
185 | } |
| |
186 | function 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]} · peak ${fmt(max)}/wk</div>`; |
| |
456 | box.appendChild(svg); |
| |
457 | host.appendChild(box); |
| |
458 | }); |
| |
459 | })(); |
| |
460 | |
| |
461 | /* ---- coverage table ---- */ |
| |
462 | table('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> |