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)