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
archive: 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 ]
135 return [html.Div(className="kpi", children=[html.Span(v, className="kpi-value"),
136 html.Span(k, className="kpi-label")])
137 for k, v in cards]
138
139
140@callback(Output("map", "figure"), *INPUTS, Input("show-cameras", "value"))
141def update_map(agencies, categories, start, end, theme, show_cameras):
142 df = filtered(agencies, categories, start, end)
143 # A density layer over a quarter-million points saturates at any radius and
144 # hides which agency is where, so plot a sample of the points instead.
145 sampled = len(df) > MAP_SAMPLE
146 if sampled:
147 df = df.sample(MAP_SAMPLE, random_state=0)
148
149 fig = go.Figure()
150 for agency, g in df.groupby("agency", sort=False):
151 fig.add_trace(go.Scattermap(
152 lat=g["lat"], lon=g["lon"], mode="markers", name=agency,
153 marker={"size": 4, "opacity": 0.45},
154 text=g["call_type"].fillna(g["category"]).fillna(g["offense_desc"]),
155 hovertemplate="%{text}<extra>" + agency + "</extra>"))
156 if show_cameras and len(CAMERAS):
157 fig.add_trace(go.Scattermap(
158 lat=CAMERAS["lat"], lon=CAMERAS["lon"], mode="markers",
159 # Amber reads against both the light and the dark basemap.
160 marker={"size": 7, "color": "#ffb300"},
161 name="ALPR camera",
162 text=[f"{m or 'unknown make'} — {o or 'operator not tagged'}"
163 for m, o in zip(CAMERAS["manufacturer"], CAMERAS["operator"])],
164 hovertemplate="%{text}<extra>ALPR</extra>"))
165 title = f"{MAP_SAMPLE:,}-incident sample" if sampled else f"{len(df):,} incidents"
166 fig.update_layout(map={"style": PALETTES[theme]["basemap"], "center": CENTER,
167 "zoom": 9.6},
168 height=560, margin={"r": 0, "t": 30, "l": 0, "b": 0},
169 title=title, uirevision="map",
170 legend={"x": 0.01, "y": 0.99})
171 return themed(fig, theme)
172
173
174@callback(Output("timeline", "figure"), *INPUTS)
175def update_timeline(agencies, categories, start, end, theme):
176 df = filtered(agencies, categories, start, end)
177 counts = analysis.daily_counts(df)
178 if counts.empty:
179 return empty(theme)
180 fig = px.line(counts, x="date", y="incidents", color="agency",
181 title="Incidents per day by agency")
182 fig.update_layout(margin={"t": 40}, hovermode="x unified")
183 return themed(fig, theme)
184
185
186@callback(Output("dispositions", "figure"), *INPUTS)
187def update_dispositions(agencies, categories, start, end, theme):
188 # Category filter is ignored: stops are identified by is_stop, not category.
189 out = analysis.stop_outcomes(filtered(agencies, None, start, end))
190 if out.empty:
191 return empty(theme)
192 fig = px.bar(out, x="rate", y="agency", color="outcome", orientation="h",
193 barmode="group", custom_data=["stops"],
194 title="Vehicle stop outcomes",
195 labels={"rate": "share of that agency's stops"})
196 fig.update_traces(hovertemplate="%{x:.1%} of %{customdata[0]:,} stops"
197 "<extra>%{fullData.name}</extra>")
198 fig.update_layout(margin={"t": 40}, xaxis_tickformat=".0%",
199 yaxis_title=None, legend_title_text=None)
200 return themed(fig, theme)
201
202
203@callback(Output("proximity", "figure"), *INPUTS)
204def update_proximity(agencies, categories, start, end, theme):
205 # Category filter is deliberately ignored: the comparison needs both stops
206 # and the non-stop baseline in the same window.
207 df = filtered(agencies, None, start, end)
208 prox = analysis.camera_proximity(df, CAMERAS)
209 if prox.empty:
210 return empty(theme)
211 fig = px.line(prox, x="distance_m", y="share", color="kind", markers=True,
212 title="Distance to nearest ALPR camera",
213 labels={"distance_m": "metres to nearest camera",
214 "share": "share of incidents"})
215 fig.update_layout(margin={"t": 40}, yaxis_tickformat=".1%")
216 return themed(fig, theme)
217
218
219@callback(Output("table", "data"), Output("table", "columns"), *INPUTS)
220def update_table(agencies, categories, start, end, _theme):
221 cols = ["occurred_at", "agency", "category", "call_type", "disposition",
222 "address"]
223 df = (filtered(agencies, categories, start, end)
224 .sort_values("occurred_at", ascending=False)
225 .head(500)[cols])
226 df = df.assign(occurred_at=df["occurred_at"].dt.strftime("%Y-%m-%d %H:%M"))
227 return df.to_dict("records"), [{"name": c, "id": c} for c in cols]
228
229
230if __name__ == "__main__":
231 app.run(debug=True)