diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b24504e..c8e67842 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,12 +14,15 @@ "autosuggest-highlight": "^3.3.0", "axios": "^0.27.2", "babel-eslint": "^10.1.0", + "chart.js": "^4.2.1", "date-fns": "^2.28.0", "framer-motion": "^4.1.17", + "lodash.clonedeep": "^4.5.0", "lodash.range": "^3.2.0", "p-min-delay": "^4.0.1", "prop-types": "^15.8.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-content-loader": "^6.2.0", "react-csv": "^2.2.2", "react-datepicker": "^4.11.0", @@ -2788,6 +2791,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -5504,6 +5512,17 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz", + "integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": "^7.0.0" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -11127,6 +11146,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -13699,6 +13723,15 @@ "node": ">=14" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-content-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-6.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2308e051..ec7b02db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,15 @@ "autosuggest-highlight": "^3.3.0", "axios": "^0.27.2", "babel-eslint": "^10.1.0", + "chart.js": "^4.2.1", "date-fns": "^2.28.0", "framer-motion": "^4.1.17", + "lodash.clonedeep": "^4.5.0", "lodash.range": "^3.2.0", "p-min-delay": "^4.0.1", "prop-types": "^15.8.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-content-loader": "^6.2.0", "react-csv": "^2.2.2", "react-datepicker": "^4.11.0", diff --git a/frontend/src/Components/App.js b/frontend/src/Components/App.js index 06b6ba8e..271e12ce 100644 --- a/frontend/src/Components/App.js +++ b/frontend/src/Components/App.js @@ -41,6 +41,20 @@ import FindAStopResults from './FindAStopResults/FindAStopResults'; import DepartmentSearch from './Elements/DepartmentSearch'; import * as S from './HomePage/HomePage.styled'; +// New Charts +import { + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from 'chart.js'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); + function App() { const [showCompare, setShowCompare] = React.useState(false); const [agencyId, setAgencyId] = React.useState(null); diff --git a/frontend/src/Components/Charts/TrafficStops/TrafficStops.js b/frontend/src/Components/Charts/TrafficStops/TrafficStops.js index 7c4c7c7f..a7af2b0f 100644 --- a/frontend/src/Components/Charts/TrafficStops/TrafficStops.js +++ b/frontend/src/Components/Charts/TrafficStops/TrafficStops.js @@ -1,7 +1,12 @@ import React, { useState, useEffect } from 'react'; -import TrafficStopsStyled from './TrafficStops.styled'; +import TrafficStopsStyled, { + GroupedStopsContainer, + LineWrapper, + StopGroupsContainer, +} from './TrafficStops.styled'; import * as S from '../ChartSections/ChartsCommon.styled'; import { useTheme } from 'styled-components'; +import cloneDeep from 'lodash.clonedeep'; // Util import { @@ -18,7 +23,7 @@ import { } from '../chartUtils'; // State -import useDataset, { STOPS_BY_REASON, STOPS } from '../../../Hooks/useDataset'; +import useDataset, { STOPS_BY_REASON, STOPS, AGENCY_DETAILS } from '../../../Hooks/useDataset'; // Elements import { P } from '../../../styles/StyledComponents/Typography'; @@ -38,8 +43,10 @@ import toTitleCase from '../../../util/toTitleCase'; import useOfficerId from '../../../Hooks/useOfficerId'; import MonthRangePicker from '../../Elements/MonthRangePicker'; import mapDatasetKeyToEndpoint from '../../../Services/endpoints'; -import axios from '../../../Services/Axios'; import range from 'lodash.range'; +import LineChart from '../../NewCharts/LineChart'; +import axios from '../../../Services/Axios'; +import NewModal from '../../NewCharts/NewModal'; function TrafficStops(props) { const { agencyId } = props; @@ -81,6 +88,9 @@ function TrafficStops(props) { const [countEthnicGroups, setCountEthnicGroups] = useState(() => STATIC_LEGEND_KEYS.map((k) => ({ ...k })) ); + const [stopPurposeEthnicGroups, setStopPurposeEthnicGroups] = useState(() => + STATIC_LEGEND_KEYS.map((k) => ({ ...k })) + ); const [byPercentageLineData, setByPercentageLineData] = useState([]); const [byPercentagePieData, setByPercentagePieData] = useState([]); @@ -90,6 +100,49 @@ function TrafficStops(props) { const renderMetaTags = useMetaTags(); const [renderTableModal, { openModal }] = useTableModal(); + const [stopPurposeGroupsData, setStopPurposeGroups] = useState({ labels: [], datasets: [] }); + const [stopsGroupedByPurposeData, setStopsGroupedByPurpose] = useState({ + labels: [], + safety: { labels: [], datasets: [] }, + regulatory: { labels: [], datasets: [] }, + other: { labels: [], datasets: [] }, + max_step_size: null, + }); + + const [stopPurposeModalData, setStopPurposeModalData] = useState({ + isOpen: false, + tableData: [], + csvData: [], + }); + + const [groupedStopPurposeModalData, setGroupedStopPurposeModalData] = useState({ + isOpen: false, + tableData: [], + csvData: [], + selectedPurpose: 'Safety Violation', + purposeTypes: ['Safety Violation', 'Regulatory and Equipment', 'Other'], + }); + + // Build Stop Purpose Groups + useEffect(() => { + axios + .get(`/api/agency/${agencyId}/stop-purpose-groups/`) + .then((res) => { + setStopPurposeGroups(res.data); + }) + .catch((err) => console.log(err)); + }, []); + + // Build Stops Grouped by Purpose + useEffect(() => { + axios + .get(`/api/agency/${agencyId}/stops-grouped-by-purpose/`) + .then((res) => { + setStopsGroupedByPurpose(res.data); + }) + .catch((err) => console.log(err)); + }, []); + /* CALCULATE AND BUILD CHART DATA */ // Build data for Stops by Percentage line chart useEffect(() => { @@ -218,6 +271,31 @@ function TrafficStops(props) { setCountEthnicGroups(updatedGroups); }; + // Handle stops grouped by purpose legend interactions + const handleStopPurposeKeySelected = (ethnicGroup) => { + const groupIndex = stopPurposeEthnicGroups.indexOf( + stopPurposeEthnicGroups.find((g) => g.value === ethnicGroup.value) + ); + const updatedGroups = [...stopPurposeEthnicGroups]; + updatedGroups[groupIndex].selected = !updatedGroups[groupIndex].selected; + setStopPurposeEthnicGroups(updatedGroups); + + const newStopPurposeState = cloneDeep(stopsGroupedByPurposeData); + newStopPurposeState.safety.datasets.forEach((s) => { + // eslint-disable-next-line no-param-reassign + s.hidden = !updatedGroups.find((g) => g.label === s.label).selected; + }); + newStopPurposeState.regulatory.datasets.forEach((r) => { + // eslint-disable-next-line no-param-reassign + r.hidden = !updatedGroups.find((g) => g.label === r.label).selected; + }); + newStopPurposeState.other.datasets.forEach((i) => { + // eslint-disable-next-line no-param-reassign + i.hidden = !updatedGroups.find((g) => g.label === i.label).selected; + }); + setStopsGroupedByPurpose(newStopPurposeState); + }; + const handleViewPercentageData = () => { setPurpose(PURPOSE_DEFAULT); openModal(STOPS, STOPS_TABLE_COLUMNS); @@ -227,6 +305,64 @@ function TrafficStops(props) { openModal(STOPS_BY_REASON, BY_REASON_TABLE_COLUMNS); }; + const showStopPurposeModal = () => { + const tableData = []; + stopPurposeGroupsData.labels.forEach((e, i) => { + const safety = stopPurposeGroupsData.datasets[0].data[i]; + const regulatory = stopPurposeGroupsData.datasets[1].data[i]; + const other = stopPurposeGroupsData.datasets[2].data[i]; + tableData.unshift({ + year: e, + safety, + regulatory, + other, + total: [safety, regulatory, other].reduce((a, b) => a + b, 0), + }); + }); + const newState = { + isOpen: true, + tableData, + csvData: tableData, + }; + setStopPurposeModalData(newState); + }; + + const showGroupedStopPurposeModal = (stopPurpose = 'Safety Violation') => { + const stopPurposeKey = { + 'Safety Violation': 'safety', + 'Regulatory and Equipment': 'regulatory', + Other: 'other', + }; + const tableData = []; + let stopPurposeSelected = 'Safety Violation'; + if (typeof stopPurpose === 'string') { + stopPurposeSelected = stopPurpose; + } + stopsGroupedByPurposeData.labels.forEach((e, y) => { + const races = ['white', 'black', 'hispanic', 'asian', 'native_american', 'other']; + const row = { + year: e, + }; + const total = []; + races.forEach((r, j) => { + // The data is indexed by the stop purpose group, then the index of the race then the index of the year. + row[r] = + stopsGroupedByPurposeData[stopPurposeKey[stopPurposeSelected]].datasets[j]['data'][y]; + total.unshift(row[r]); + }); + row['total'] = total.reduce((a, b) => a + b, 0); + tableData.unshift(row); + }); + const newState = { + ...groupedStopPurposeModalData, + isOpen: true, + tableData, + csvData: tableData, + selectedPurpose: stopPurposeSelected, + }; + setGroupedStopPurposeModalData(newState); + }; + const getChartDetailedBreakdown = () => { const selectedGroups = percentageEthnicGroups.filter((k) => k.selected).map((k) => k.label); if (selectedGroups.length < percentageEthnicGroups.length) { @@ -376,6 +512,98 @@ function TrafficStops(props) { + + +

Shows the number of traffics stops broken down by purpose and race / ethnicity.

+ setStopPurposeModalData((state) => ({ ...state, isOpen: false }))} + /> + + + + + +
+ + +

Shows the number of traffics stops broken down by purpose and race / ethnicity.

+ + + setGroupedStopPurposeModalData((state) => ({ ...state, isOpen: false })) + } + > + + + + + + + + + + + + + +
); } @@ -455,3 +683,61 @@ const BY_REASON_TABLE_COLUMNS = [ accessor: 'total', }, ]; + +const STOP_PURPOSE_TABLE_COLUMNS = [ + { + Header: 'Year', + accessor: 'year', + }, + { + Header: 'Safety Violation', + accessor: 'safety', + }, + { + Header: 'Regulatory and Equipment', + accessor: 'regulatory', + }, + { + Header: 'Other', + accessor: 'other', + }, + { + Header: 'Total', + accessor: 'total', + }, +]; + +const GROUPED_STOP_PURPOSE_TABLE_COLUMNS = [ + { + Header: 'Year', + accessor: 'year', + }, + { + Header: 'White', + accessor: 'white', + }, + { + Header: 'Black', + accessor: 'black', + }, + { + Header: 'Hispanic', + accessor: 'hispanic', + }, + { + Header: 'Asian', + accessor: 'asian', + }, + { + Header: 'Native American', + accessor: 'native_american', + }, + { + Header: 'Other', + accessor: 'other', + }, + { + Header: 'Total', + accessor: 'total', + }, +]; diff --git a/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js b/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js index 4f6a568b..b6f872b7 100644 --- a/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js +++ b/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js @@ -1,4 +1,31 @@ import styled from 'styled-components'; import ChartPageBase from '../ChartSections/ChartPageBase'; +import { smallerThanDesktop, smallerThanTabletLandscape } from '../../../styles/breakpoints'; export default styled(ChartPageBase)``; + +export const LineWrapper = styled.div` + display: flex; + flex-direction: row; + flex-wrap: no-wrap; + width: 85%%; + margin: 0 auto; + justify-content: space-evenly; + + @media (${smallerThanDesktop}) { + flex-wrap: wrap; + } +`; + +export const StopGroupsContainer = styled.div` + width: 100%; + height: 500px; +`; + +export const GroupedStopsContainer = styled.div` + width: 33%; + height: 500px; + @media (${smallerThanTabletLandscape}) { + width: 100%; + } +`; diff --git a/frontend/src/Components/Elements/Table/Table.styled.js b/frontend/src/Components/Elements/Table/Table.styled.js index 90f671a4..38fb8bfa 100644 --- a/frontend/src/Components/Elements/Table/Table.styled.js +++ b/frontend/src/Components/Elements/Table/Table.styled.js @@ -5,6 +5,7 @@ export const TableWrapper = styled.div` width: 100%; max-width: 1200px; margin: 0 auto; + overflow-y: auto; `; export const Table = styled.table` @@ -40,7 +41,6 @@ export const THInner = styled.div` flex-direction: row; justify-content: space-between; align-items: center; - max-width: 75px; `; export const OfficerIdIcon = styled(FJIcon)` diff --git a/frontend/src/Components/NewCharts/LineChart.js b/frontend/src/Components/NewCharts/LineChart.js new file mode 100644 index 00000000..64f6237b --- /dev/null +++ b/frontend/src/Components/NewCharts/LineChart.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; + +export default function LineChart({ + data, + title, + maintainAspectRatio = true, + displayTitle = true, + displayLegend = true, + yAxisMax = null, + yAxisShowLabels = true, +}) { + const options = { + responsive: true, + maintainAspectRatio, + hover: { + mode: 'nearest', + intersect: false, + }, + plugins: { + legend: { + display: displayLegend, + position: 'top', + }, + tooltip: { + mode: 'nearest', + intersect: false, + }, + title: { + display: displayTitle, + text: title, + }, + }, + scales: { + y: { + max: yAxisMax, + ticks: { + display: yAxisShowLabels, + }, + }, + }, + }; + + return ; +} diff --git a/frontend/src/Components/NewCharts/NewModal.js b/frontend/src/Components/NewCharts/NewModal.js new file mode 100644 index 00000000..454510fb --- /dev/null +++ b/frontend/src/Components/NewCharts/NewModal.js @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { useTheme } from 'styled-components'; +import usePortal from '../../Hooks/usePortal'; +import ReactDOM from 'react-dom'; +import * as S from '../Elements/Table/TableModal.styled'; +import { H2, P } from '../../styles/StyledComponents/Typography'; +import { ICONS } from '../../img/icons/Icon'; +import Button from '../Elements/Button'; +import TableSkeleton from '../Elements/Skeletons/TableSkeleton'; +import Table from '../Elements/Table/Table'; +import { CSVLink } from 'react-csv'; +import useOfficerId from '../../Hooks/useOfficerId'; + +export default function NewModal({ + tableHeader, + tableSubheader, + agencyName, + tableData, + csvData, + columns, + tableDownloadName, + isOpen, + closeModal, + children, +}) { + const theme = useTheme(); + const portalTarget = usePortal('modal-root'); + const [tableLoading] = useState(false); + const officerId = useOfficerId(); + + // Close modal on "esc" press + useEffect(() => { + function _handleKeyUp(e) { + if (e.key === 'Escape') { + document.body.style.overflow = 'visible'; + closeModal(); + } + } + + document.addEventListener('keyup', _handleKeyUp); + return () => document.removeEventListener('keyup', _handleKeyUp); + }, [closeModal]); + + const _getEntityReference = () => { + if (officerId) return `for Officer ${officerId} of the ${agencyName}`; + return `for ${agencyName}`; + }; + + return ReactDOM.createPortal( + isOpen && ( + <> + + + + +

{tableHeader}

+

{_getEntityReference()}

+
+ +
+ +

{tableSubheader}

+
+ + {children} + + + {tableLoading ? ( + + ) : ( + + )} + + * Non-hispanic + {!tableLoading && ( + + + + + + )} + + + ), + portalTarget + ); +} diff --git a/nc/models.py b/nc/models.py index 00d71147..a357687b 100755 --- a/nc/models.py +++ b/nc/models.py @@ -4,6 +4,52 @@ from tsdata.models import CensusProfile + +class StopPurpose(models.IntegerChoices): + SPEED_LIMIT_VIOLATION = 1, "Speed Limit Violation" # Safety Violation + STOP_LIGHT_SIGN_VIOLATION = 2, "Stop Light/Sign Violation" # Safety Violation + DRIVING_WHILE_IMPAIRED = 3, "Driving While Impaired" # Safety Violation + SAFE_MOVEMENT_VIOLATION = 4, "Safe Movement Violation" # Safety Violation + VEHICLE_EQUIPMENT_VIOLATION = 5, "Vehicle Equipment Violation" # Regulatory and Equipment + VEHICLE_REGULATORY_VIOLATION = 6, "Vehicle Regulatory Violation" # Regulatory and Equipment + OTHER_MOTOR_VEHICLE_VIOLATION = 9, "Other Motor Vehicle Violation" # Regulatory and Equipment + SEAT_BELT_VIOLATION = 7, "Seat Belt Violation" # Regulatory and Equipment + INVESTIGATION = 8, "Investigation" # Other + CHECKPOINT = 10, "Checkpoint" # Other + + +class StopPurposeGroup(models.TextChoices): + + SAFETY_VIOLATION = "Safety Violation" + REGULATORY_EQUIPMENT = "Regulatory and Equipment" + OTHER = "Other" + + @classmethod + def safety_violation_purposes(cls): + return [ + StopPurpose.SPEED_LIMIT_VIOLATION.value, + StopPurpose.STOP_LIGHT_SIGN_VIOLATION.value, + StopPurpose.DRIVING_WHILE_IMPAIRED.value, + StopPurpose.SAFE_MOVEMENT_VIOLATION.value, + ] + + @classmethod + def regulatory_purposes(cls): + return [ + StopPurpose.VEHICLE_EQUIPMENT_VIOLATION.value, + StopPurpose.VEHICLE_REGULATORY_VIOLATION.value, + StopPurpose.OTHER_MOTOR_VEHICLE_VIOLATION.value, + StopPurpose.SEAT_BELT_VIOLATION.value, + ] + + @classmethod + def other_purposes(cls): + return [ + StopPurpose.INVESTIGATION.value, + StopPurpose.CHECKPOINT.value, + ] + + PURPOSE_CHOICES = ( (1, "Speed Limit Violation"), (2, "Stop Light/Sign Violation"), @@ -35,6 +81,20 @@ ETHNICITY_CHOICES = (("H", "Hispanic"), ("N", "Non-Hispanic")) +class DriverRace(models.TextChoices): + ASIAN = "A", "Asian" + BLACK = "B", "Black" + HISPANIC = "H", "Hispanic" + NATIVE_AMERICAN = "I", "Native American" + OTHER = "U", "Other" + WHITE = "W", "White" + + +class DriverEthnicity(models.TextChoices): + HISPANIC = "H", "Hispanic" + NON_HISPANIC = "N", "Non-Hispanic" + + RACE_CHOICES = ( ("A", "Asian"), ("B", "Black"), @@ -164,12 +224,17 @@ def census_profile(self): return dict() -STOP_SUMMARY_VIEW_SQL = """ +STOP_SUMMARY_VIEW_SQL = f""" SELECT ROW_NUMBER() OVER () AS id , "nc_stop"."agency_id" , DATE_TRUNC('month', date AT TIME ZONE 'America/New_York')::date AS "date" , "nc_stop"."purpose" AS "stop_purpose" + , (CASE WHEN nc_stop.purpose IN ({",".join(map(str, StopPurposeGroup.safety_violation_purposes()))}) THEN 'Safety Violation' + WHEN nc_stop.purpose IN ({",".join(map(str, StopPurposeGroup.other_purposes()))}) THEN 'Other' + WHEN nc_stop.purpose IN ({",".join(map(str, StopPurposeGroup.regulatory_purposes()))}) THEN 'Regulatory and Equipment' + ELSE 'Other' + END) as stop_purpose_group , "nc_stop"."engage_force" , "nc_search"."type" AS "search_type" , (CASE @@ -179,6 +244,13 @@ def census_profile(self): , "nc_stop"."officer_id" , "nc_person"."race" AS "driver_race" , "nc_person"."ethnicity" AS "driver_ethnicity" + , (CASE WHEN nc_person.ethnicity = 'H' THEN 'Hispanic' + WHEN nc_person.ethnicity = 'N' AND nc_person.race = 'A' THEN 'Asian' + WHEN nc_person.ethnicity = 'N' AND nc_person.race = 'B' THEN 'Black' + WHEN nc_person.ethnicity = 'N' AND nc_person.race = 'I' THEN 'Native American' + WHEN nc_person.ethnicity = 'N' AND nc_person.race = 'U' THEN 'Other' + WHEN nc_person.ethnicity = 'N' AND nc_person.race = 'W' THEN 'White' + END) as driver_race_comb , COUNT("nc_stop"."date")::integer AS "count" FROM "nc_stop" INNER JOIN "nc_person" @@ -188,7 +260,7 @@ def census_profile(self): LEFT OUTER JOIN "nc_contraband" ON ("nc_stop"."stop_id" = "nc_contraband"."stop_id") GROUP BY - 2, 3, 4, 5, 6, 7, 8, 9, 10 + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 ORDER BY "agency_id", "date" ASC; """ # noqa @@ -203,13 +275,15 @@ class StopSummary(pg.ReadOnlyMaterializedView): id = models.PositiveIntegerField(primary_key=True) date = models.DateField() agency = models.ForeignKey("Agency", on_delete=models.DO_NOTHING) - stop_purpose = models.PositiveSmallIntegerField(choices=PURPOSE_CHOICES) + stop_purpose = models.PositiveSmallIntegerField(choices=StopPurpose.choices) + stop_purpose_group = models.CharField(choices=StopPurposeGroup.choices, max_length=32) engage_force = models.BooleanField() search_type = models.PositiveSmallIntegerField(choices=SEARCH_TYPE_CHOICES) contraband_found = models.BooleanField() officer_id = models.CharField(max_length=15) driver_race = models.CharField(max_length=2, choices=RACE_CHOICES) driver_ethnicity = models.CharField(max_length=2, choices=ETHNICITY_CHOICES) + driver_race_comb = models.CharField(max_length=2, choices=DriverRace.choices) count = models.IntegerField() class Meta: diff --git a/nc/urls.py b/nc/urls.py index d9d3005b..43611c7b 100755 --- a/nc/urls.py +++ b/nc/urls.py @@ -15,4 +15,12 @@ urlpatterns = [ # noqa re_path(r"^api/", include(router.urls)), path("api/about/contact/", csrf_exempt(views.ContactView.as_view()), name="contact-form"), + path( + "api/agency//stop-purpose-groups/", + views.AgencyStopPurposeGroupView.as_view(), + ), + path( + "api/agency//stops-grouped-by-purpose/", + views.AgencyStopGroupByPurposeView.as_view(), + ), ] diff --git a/nc/views.py b/nc/views.py index 362dcf2e..79eeeb32 100644 --- a/nc/views.py +++ b/nc/views.py @@ -1,5 +1,10 @@ import datetime +from functools import reduce +from operator import concat + +import pandas as pd + from dateutil import relativedelta from django.conf import settings from django.core.mail import send_mail @@ -19,7 +24,7 @@ from nc import serializers from nc.filters import DriverStopsFilter from nc.models import SEARCH_TYPE_CHOICES as SEARCH_TYPE_CHOICES_TUPLES -from nc.models import Agency, Contraband, Person, Resource, StopSummary +from nc.models import Agency, Contraband, Person, Resource, StopPurposeGroup, StopSummary from nc.pagination import NoCountPagination from nc.serializers import ContactFormSerializer from tsdata.models import StateFacts @@ -100,7 +105,9 @@ def get_date_range(self): delta = relativedelta.relativedelta(to_date, from_date) if delta.years < 3: date_precision = "month" - to_date += relativedelta.relativedelta(months=1) + to_date = ( + to_date + relativedelta.relativedelta(months=1) + ) - datetime.timedelta(days=1) date_range = Q(date__range=(from_date, to_date)) return date_precision, date_range @@ -119,11 +126,7 @@ def query(self, results, group_by, filter_=None): if date_precision == "year": qs = qs.annotate(year=ExtractYear("date")) elif date_precision == "month": - # TODO: Cleanup this up, maybe use lists as default? - results_gb = list(results.group_by) - results_gb.remove("year") - results_gb.append("date") - results.group_by = tuple(results_gb) + results.group_by = ("date",) gp_list = list(group_by_tuple) gp_list.remove("year") gp_list.append("date") @@ -365,3 +368,133 @@ def post(self, request): return Response(status=204) else: return Response(data=serializer.errors, status=400) + + +class AgencyStopPurposeGroupView(APIView): + def get(self, request, agency_id): + qs = ( + StopSummary.objects.filter(agency_id=agency_id) + .annotate(year=ExtractYear("date")) + .values("year", "stop_purpose_group") + .annotate(count=Sum("count")) + .order_by("year") + ) + df = pd.DataFrame(qs) + unique_years = df.year.unique() + pivot_df = df.pivot(index="year", columns="stop_purpose_group", values="count").fillna( + value=0 + ) + df = pd.DataFrame(pivot_df) + data = { + "labels": unique_years, + "datasets": [ + { + "label": StopPurposeGroup.SAFETY_VIOLATION, + "data": list(df[StopPurposeGroup.SAFETY_VIOLATION].values), + "borderColor": "#7F428A", + "backgroundColor": "#CFA9D6", + }, + { + "label": StopPurposeGroup.REGULATORY_EQUIPMENT, + "data": list(df[StopPurposeGroup.REGULATORY_EQUIPMENT].values), + "borderColor": "#b36800", + "backgroundColor": "#ffa500", + }, + { + "label": StopPurposeGroup.OTHER, + "data": list(df[StopPurposeGroup.OTHER].values), + "borderColor": "#1B4D3E", + "backgroundColor": "#ACE1AF", + }, + ], + } + return Response(data=data, status=200) + + +class AgencyStopGroupByPurposeView(APIView): + def group_by_purpose(self, df, purpose, years): + return { + "labels": years, + "datasets": [ + { + "label": "White", + "data": list(df[purpose]["White"].values), + "borderColor": "#02bcbb", + "backgroundColor": "#80d9d8", + }, + { + "label": "Black", + "data": list(df[purpose]["Black"].values), + "borderColor": "#8879fc", + "backgroundColor": "#beb4fa", + }, + { + "label": "Hispanic", + "data": list(df[purpose]["Hispanic"].values), + "borderColor": "#9c0f2e", + "backgroundColor": "#ca8794", + }, + { + "label": "Asian", + "data": list(df[purpose]["Asian"].values), + "borderColor": "#ffe066", + "backgroundColor": "#ffeeb2", + }, + { + "label": "Native American", + "data": list(df[purpose]["Native American"].values), + "borderColor": "#0c3a66", + "backgroundColor": "#8598ac", + }, + { + "label": "Other", + "data": list(df[purpose]["Other"].values), + "borderColor": "#9e7b9b", + "backgroundColor": "#cab6c7", + }, + ], + } + + def get(self, request, agency_id): + qs = ( + StopSummary.objects.filter(agency_id=agency_id) + .annotate(year=ExtractYear("date")) + .values("year", "driver_race_comb", "stop_purpose_group") + .annotate(count=Sum("count")) + .order_by("year") + ) + df = pd.DataFrame(qs) + unique_years = df.year.unique() + pivot_table = pd.pivot_table( + df, index="year", columns=["stop_purpose_group", "driver_race_comb"], values="count" + ).fillna(value=0) + pivot_df = pd.DataFrame(pivot_table) + + safety_data = self.group_by_purpose( + pivot_df, StopPurposeGroup.SAFETY_VIOLATION, unique_years + ) + regulatory_data = self.group_by_purpose( + pivot_df, StopPurposeGroup.REGULATORY_EQUIPMENT, unique_years + ) + other_data = self.group_by_purpose(pivot_df, StopPurposeGroup.OTHER, unique_years) + + # Get the max value to keep the graphs consistent when + # next to each other by setting the max y value + max_step_size = max( + reduce( + concat, + [d["data"] for d in safety_data["datasets"]] + + [d["data"] for d in regulatory_data["datasets"]] + + [d["data"] for d in other_data["datasets"]], + ) + ) + + data = { + "labels": unique_years, + "safety": safety_data, + "regulatory": regulatory_data, + "other": other_data, + "max_step_size": round(max_step_size, -3) + 1000, # Round to nearest 100 + } + + return Response(data=data, status=200)