krz/omaha-metro-blotter
Archive of police activity and ALPR surveillance across the Omaha metro.
clone: git clone https://gitbay.org/krz/omaha-metro-blotter.git
flock-search-audit: app.py · raw
1"""Omaha metro police activity dashboard.
2
3Covers Omaha PD, Council Bluffs PD and the Sarpy County agencies (Bellevue,
4Papillion, La Vista, Sheriff). Ralston PD publishes no machine-readable feed and
5is absent. OPD publishes NIBRS offence records with no stop or disposition data,
6so it contributes nothing to the enforcement panels.
7"""
8
9from dash import Dash, Input, Output, callback, dash_table, dcc, html
10import plotly.express as px
11import plotly.graph_objects as go
12
13import analysis
14
15conn = analysis.connect()
16INCIDENTS = analysis.load_incidents(conn)
17CAMERAS = analysis.load_cameras(conn)
18AGENCIES = analysis.agency_options(conn)
19CATEGORIES = analysis.category_options(conn)
20DATE_LO, DATE_HI = analysis.date_bounds(conn)
21conn.close()
22
23DEFAULT_AGENCIES = [a for a in analysis.POLICE_AGENCIES if a in AGENCIES]
24CENTER = {"lat": 41.21, "lon": -95.97}
25MAP_SAMPLE = 15000
26# Plotly writes colours into the figure, so the scheme has to be known before a
27# figure is built. assets/theme.js reports the media query into the theme store
28# and every figure callback reads it; nothing is restyled after the fact.
29PALETTES = {
30 "light": {"fg": "#111", "grid": "#e6e6e6", "legend": "rgba(255,255,255,.85)",
31 "basemap": "open-street-map"},
32 "dark": {"fg": "#e8e8ea", "grid": "#333840", "legend": "rgba(27,30,36,.85)",
33 "basemap": "carto-darkmatter"},
34}
35
36
37def themed(fig, theme):
38 p = PALETTES.get(theme, PALETTES["light"])
39 fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
40 font_color=p["fg"], legend_bgcolor=p["legend"],
41 legend_font_color=p["fg"])
42 # update_xaxes would bolt empty axis objects onto the map figure, which has
43 # no cartesian axes at all.
44 if not any(tr.type == "scattermap" for tr in fig.data):
45 fig.update_xaxes(gridcolor=p["grid"], zerolinecolor=p["grid"])
46 fig.update_yaxes(gridcolor=p["grid"], zerolinecolor=p["grid"])
47 return fig
48
49
50def empty(theme):
51 return themed(go.Figure().update_layout(
52 annotations=[{"text": "No incidents match these filters",
53 "showarrow": False, "font": {"size": 14}}],
54 xaxis={"visible": False}, yaxis={"visible": False}), theme)
55
56app = Dash(__name__)
57app.title = "Omaha Metro Police Activity"
58
59app.layout = html.Div([
60 dcc.Store(id="theme", data="light"),
61 html.H1("Omaha Metro Police Activity"),
62 html.P([
63 f"{len(INCIDENTS):,} geolocated incidents, {DATE_LO} to {DATE_HI}. ",
64 f"{len(CAMERAS)} ALPR cameras from OpenStreetMap. ",
65 "Ralston PD publishes no feed and is not represented. ",
66 "Omaha PD reports no stops or dispositions, so it is absent from the ",
67 "enforcement panels below.",
68 ], className="subtitle"),
69
70 html.Div(className="controls", children=[
71 html.Div([html.Label("Agency"),
72 dcc.Dropdown(AGENCIES, DEFAULT_AGENCIES, id="agencies",
73 multi=True)]),
74 html.Div([html.Label("Category"),
75 dcc.Dropdown(CATEGORIES, [], id="categories", multi=True,
76 placeholder="all categories")]),
77 html.Div([html.Label("Dates"),
78 dcc.DatePickerRange(id="dates", min_date_allowed=DATE_LO,
79 max_date_allowed=DATE_HI,
80 start_date=DATE_LO, end_date=DATE_HI)]),
81 html.Div([html.Label("Show cameras"),
82 dcc.Checklist([{"label": " ALPR layer", "value": "on"}],
83 ["on"], id="show-cameras")]),
84 ]),
85
86 html.Div(id="kpis", className="kpis"),
87
88 dcc.Graph(id="map"),
89 dcc.Graph(id="timeline"),
90 html.Div(className="row", children=[
91 dcc.Graph(id="dispositions", className="half"),
92 dcc.Graph(id="proximity", className="half"),
93 ]),
94 html.H2("Incidents"),
95 dash_table.DataTable(id="table", page_size=10, sort_action="native",
96 style_table={"overflowX": "auto"}),
97])
98
99
100def filtered(agencies, categories, start, end):
101 df = INCIDENTS
102 if agencies:
103 df = df[df["agency"].isin(agencies)]
104 if categories:
105 df = df[df["category"].isin(categories)]
106 if start:
107 df = df[df["occurred_at"] >= start]
108 if end:
109 df = df[df["occurred_at"] <= f"{end[:10]} 23:59:59"]
110 return df
111
112
113INPUTS = [Input("agencies", "value"), Input("categories", "value"),
114 Input("dates", "start_date"), Input("dates", "end_date"),
115 Input("theme", "data")]
116
117
118@callback(Output("kpis", "children"), *INPUTS)
119def update_kpis(agencies, categories, start, end, _theme):
120 df = filtered(agencies, categories, start, end)
121 stops = df[df["is_stop"] == 1]
122 # Rate over the span the stops actually cover, not the full incident range:
123 # OPD reaches back to 2022 but reports no stops at all.
124 if len(stops):
125 span = (stops["occurred_at"].max() - stops["occurred_at"].min()).days
126 rate = f"{len(stops) / max(span, 1):.1f}"
127 else:
128 rate = "0"
129 cards = [
130 ("Incidents", f"{len(df):,}"),
131 ("Vehicle stops", f"{len(stops):,}"),
132 ("Stops per day", rate),
133 ("Agencies", f"{df['agency'].nunique()}"),
134 ("Amended since filing", f"{int(df['amended'].sum()):,}"),
135 ]
136 return [html.Div(className="kpi", children=[html.Span(v, className="kpi-value"),
137 html.Span(k, className="kpi-label")])
138 for k, v in cards]
139
140
141@callback(Output("map", "figure"), *INPUTS, Input("show-cameras", "value"))
142def update_map(agencies, categories, start, end, theme, show_cameras):
143 df = filtered(agencies, categories, start, end)
144 # A density layer over a quarter-million points saturates at any radius and
145 # hides which agency is where, so plot a sample of the points instead.
146 sampled = len(df) > MAP_SAMPLE
147 if sampled:
148 df = df.sample(MAP_SAMPLE, random_state=0)
149
150 fig = go.Figure()
151 for agency, g in df.groupby("agency", sort=False):
152 fig.add_trace(go.Scattermap(
153 lat=g["lat"], lon=g["lon"], mode="markers", name=agency,
154 marker={"size": 4, "opacity": 0.45},
155 text=g["call_type"].fillna(g["category"]).fillna(g["offense_desc"]),
156 hovertemplate="%{text}<extra>" + agency + "</extra>"))
157 if show_cameras and len(CAMERAS):
158 fig.add_trace(go.Scattermap(
159 lat=CAMERAS["lat"], lon=CAMERAS["lon"], mode="markers",
160 # Amber reads against both the light and the dark basemap.
161 marker={"size": 7, "color": "#ffb300"},
162 name="ALPR camera",
163 text=[f"{m or 'unknown make'} — {o or 'operator not tagged'}"
164 for m, o in zip(CAMERAS["manufacturer"], CAMERAS["operator"])],
165 hovertemplate="%{text}<extra>ALPR</extra>"))
166 title = f"{MAP_SAMPLE:,}-incident sample" if sampled else f"{len(df):,} incidents"
167 fig.update_layout(map={"style": PALETTES[theme]["basemap"], "center": CENTER,
168 "zoom": 9.6},
169 height=560, margin={"r": 0, "t": 30, "l": 0, "b": 0},
170 title=title, uirevision="map",
171 legend={"x": 0.01, "y": 0.99})
172 return themed(fig, theme)
173
174
175@callback(Output("timeline", "figure"), *INPUTS)
176def update_timeline(agencies, categories, start, end, theme):
177 df = filtered(agencies, categories, start, end)
178 counts = analysis.daily_counts(df)
179 if counts.empty:
180 return empty(theme)
181 fig = px.line(counts, x="date", y="incidents", color="agency",
182 title="Incidents per day by agency")
183 fig.update_layout(margin={"t": 40}, hovermode="x unified")
184 return themed(fig, theme)
185
186
187@callback(Output("dispositions", "figure"), *INPUTS)
188def update_dispositions(agencies, categories, start, end, theme):
189 # Category filter is ignored: stops are identified by is_stop, not category.
190 out = analysis.stop_outcomes(filtered(agencies, None, start, end))
191 if out.empty:
192 return empty(theme)
193 fig = px.bar(out, x="rate", y="agency", color="outcome", orientation="h",
194 barmode="group", custom_data=["stops"],
195 title="Vehicle stop outcomes",
196 labels={"rate": "share of that agency's stops"})
197 fig.update_traces(hovertemplate="%{x:.1%} of %{customdata[0]:,} stops"
198 "<extra>%{fullData.name}</extra>")
199 fig.update_layout(margin={"t": 40}, xaxis_tickformat=".0%",
200 yaxis_title=None, legend_title_text=None)
201 return themed(fig, theme)
202
203
204@callback(Output("proximity", "figure"), *INPUTS)
205def update_proximity(agencies, categories, start, end, theme):
206 # Category filter is deliberately ignored: the comparison needs both stops
207 # and the non-stop baseline in the same window.
208 df = filtered(agencies, None, start, end)
209 prox = analysis.camera_proximity(df, CAMERAS)
210 if prox.empty:
211 return empty(theme)
212 fig = px.line(prox, x="distance_m", y="share", color="kind", markers=True,
213 title="Distance to nearest ALPR camera",
214 labels={"distance_m": "metres to nearest camera",
215 "share": "share of incidents"})
216 fig.update_layout(margin={"t": 40}, yaxis_tickformat=".1%")
217 return themed(fig, theme)
218
219
220@callback(Output("table", "data"), Output("table", "columns"), *INPUTS)
221def update_table(agencies, categories, start, end, _theme):
222 cols = ["occurred_at", "agency", "category", "call_type", "disposition",
223 "address"]
224 df = (filtered(agencies, categories, start, end)
225 .sort_values("occurred_at", ascending=False)
226 .head(500)[cols])
227 df = df.assign(occurred_at=df["occurred_at"].dt.strftime("%Y-%m-%d %H:%M"))
228 return df.to_dict("records"), [{"name": c, "id": c} for c in cols]
229
230
231if __name__ == "__main__":
232 app.run(debug=True)