Click a feature or use search to view details.
MI Crime Analysis is a custom Spatial Intelligence Dashboard for analysing Michigan crime data reported under the FBI’s National Incident-Based Reporting System (NIBRS). Every point on the map is a Michigan law-enforcement agency, symbolised by agency type and carrying a full offence profile for the reporting year.
The app is built on the ArcGIS Maps SDK for JavaScript v5
and the Calcite Design System v5. It demonstrates
server-side filtering, live extent-based analytics, multi-source
search, and the modern arcgis-* web-component
architecture — all published from a hosted feature service
on ArcGIS Online.
Crime analysts, researchers, journalists, municipal officials, and citizens who need an honest, agency-level view of Michigan crime statistics — without scrolling through PDF tables or writing SQL.
definitionExpression filtering ·
multi-source search · Calcite v5 UI.
The National Incident-Based Reporting System is the FBI’s modern incident-level crime-reporting standard, administered through the Uniform Crime Reporting (UCR) Program. On January 1, 2021 NIBRS replaced the older Summary Reporting System (SRS) as the national standard for reporting crime to the FBI.
Where SRS counted only the single most serious offence per incident (the “hierarchy rule”), NIBRS captures every offence, every victim, every offender, every weapon, every piece of property, and every arrestee tied to an incident — giving analysts far richer signal.
NIBRS offences are split into two groups:
Group A offences roll up into the three high-level categories shown in the Crimes Summary panel:
The map you’re looking at is the end of a seven-step pipeline that turns the FBI’s raw NIBRS CSV drop into a geospatial feature service. The pipeline runs once per reporting year (2022 → 2023 → 2024 …) and is fully documented so next year’s refresh is a checklist, not a research project.
MI-2023.zip).C:\NIBRS\MI-YYYY\MI\ — every CSV lives in one flat folder.localhost:5432.nibrs_YYYY_michigan. One database per year keeps schema drift contained.postgres_setup.sql that creates the nibrs schema plus every lookup and fact table.COPY (not \COPY) so the command runs inside DBeaver’s JDBC session.'C:/NIBRS/MI-YYYY/MI/FILE.csv'.WITH (FORMAT csv, HEADER true, NULL '').NIBRS_BIAS_LIST), add ENCODING 'WIN1251' to that one COPY.Before the bulk load, disable referential-integrity triggers on every FK-constrained table:
ALTER TABLE tablename DISABLE TRIGGER ALL;
Then COPY the “straight” fact tables — the ones whose CSV aligns perfectly with the target schema — in an order that minimises FK friction:
nibrs_month → nibrs_incident → nibrs_offensenibrs_property, nibrs_property_desc, nibrs_suspect_using, nibrs_suspected_drugnibrs_victim (+ circumstances, + injury, + offense), nibrs_weapon, nibrs_bias_motivationSome CSVs don’t load cleanly. Typical symptoms:
ERROR: extra data after last expected column — trailing empty column (seen in 2023 Group B arrestee).ERROR: missing data for column "X" — fewer CSV fields than target (e.g. 2024 Group B arrestee omits high age_range_* fields).ERROR: invalid input syntax for type smallint: 'NS'/'BB' — non-numeric literals in numeric columns.The fix is a TEMP staging pattern:
CREATE TEMP TABLE temp_arrestee (columns as TEXT matching CSV order exactly);
COPY temp_arrestee FROM 'C:/NIBRS/MI-YYYY/MI/arrestee.csv'
WITH (FORMAT csv, HEADER true, NULL '');
INSERT INTO nibrs_arrestee (col1, col2, …)
SELECT
CAST(…)::bigint,
CASE WHEN txt_col ~ '^[0-9]+$' THEN txt_col::smallint ELSE NULL END,
CASE WHEN sex_code IN ('', 'NS') THEN NULL ELSE LEFT(sex_code, 1) END,
…
FROM temp_arrestee;
Used on arrestee, arrestee_groupb, victim, and sometimes offender depending on the year.
ALTER TABLE … ENABLE TRIGGER ALL;SELECT COUNT(*) FROM nibrs_… vs the CSV header count.agency_offense_pivot_summary_2023.agency_id for snappy ArcGIS joins.agency_id. Reconcile nibrs_missing_in_police and police_extra mismatches before publishing.
The core query is agency_offense_category_summary —
a PostgreSQL view that joins agencies to incidents, offences, and
offence types, then SUMs each NIBRS offence category
into its own column with a CASE expression:
CREATE OR REPLACE VIEW nibrs.agency_offense_category_summary AS
SELECT
a.agency_id,
COUNT(o.offense_id) AS total_offenses,
-- Category summaries (one CASE per NIBRS offence category)
COALESCE(SUM(CASE WHEN ot.offense_category_name = 'Homicide Offenses'
THEN 1 ELSE 0 END), 0) AS "Homicide_Offenses",
COALESCE(SUM(CASE WHEN ot.offense_category_name = 'Sex Offenses'
THEN 1 ELSE 0 END), 0) AS "Sex_Offenses",
COALESCE(SUM(CASE WHEN ot.offense_category_name = 'Larceny/Theft Offenses'
THEN 1 ELSE 0 END), 0) AS "Larceny_Theft_Offenses",
COALESCE(SUM(CASE WHEN ot.offense_category_name = 'Robbery'
THEN 1 ELSE 0 END), 0) AS "Robbery",
COALESCE(SUM(CASE WHEN ot.offense_category_name = 'Assault Offenses'
THEN 1 ELSE 0 END), 0) AS "Assault_Offenses",
-- … (28 more CASE rollups: Burglary, Motor_Vehicle_Theft,
-- Arson, Fraud_Offenses, Drug_Narcotic_Offenses,
-- Weapon_Law_Violations, Human_Trafficking, …)
COALESCE(SUM(CASE WHEN ot.offense_category_name = 'All Other Offenses'
THEN 1 ELSE 0 END), 0) AS "All_Other_Offenses"
FROM nibrs.agencies a
LEFT JOIN nibrs.nibrs_incident i ON a.agency_id = i.agency_id
LEFT JOIN nibrs.nibrs_offense o ON i.incident_id = o.incident_id
LEFT JOIN nibrs.nibrs_offense_type ot ON o.offense_type_id = ot.offense_type_id
GROUP BY a.agency_id
ORDER BY a.agency_id;
The full script lives on the workstation at
C:\Users\gregg\AppData\Roaming\DBeaverData\workspace6\General\Scripts\agency_offense_category_summary.sql
and contains all 33 category rollups. For the
published feature service the view is joined against the
Michigan gazetteer agency point layer on agency_id
and the three high-level NIBRS crime-category totals
(Persons / Property / Society) are added as computed fields.
The hosted feature service that powers this map has 65 attributes per agency, grouped in three bands. Fields 1–13 are agency metadata and population; the next block is the three high-level Crimes-Against rollups; the remainder are the individual NIBRS offences, organised so Group A maps cleanly to the three categories used in the sidebar bar chart.
The bars in the Crimes Summary panel are server-side
SUMs over the red/blue/gold blocks above. The
popup shows all 65 fields, grouped the same way, so every
individual offence is visible per agency.
COPY habit even for files that used to load clean; it prevents silent mis-alignment.
Use the Filter dropdown in the header to
isolate a specific agency type (e.g. only City
agencies, or only State Police). The filter applies
as a server-side definitionExpression on the
feature layer, so the map reflects the true filter state
even for agencies that haven’t yet loaded into the
client cache.
The coloured bars are live, not static. A reactive watcher on the view’s extent re-queries the visible features on every pan / zoom and sums:
The agency count above the bars tells you how many agencies contributed to the visible totals.
Click any agency point (or pick one via Search) and the Details card replaces its placeholder with the full agency profile — name, population, total offences, and the native Feature widget with its amCharts 5 bar charts comparing violent- and property-crime rates to the national average.
Top-right chevron icons expand the Layer List and Legend panels. Use them to toggle overlays or confirm what each symbol means.
@arcgis/core ES modules + arcgis-* web components.@esri/calcite-components for every UI primitive (shell, navigation, panel, dialog, tabs, accordion).main branch on GitHub.allthingsspatial.maps.arcgis.com.arcgis/…).Designed, built, and maintained by All Things Spatial, LLC. The pipeline, schema, and ETL approach were refined across the Michigan 2022, 2023, and 2024 NIBRS drops and are now a repeatable yearly checklist.