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)