From 417aaeaf09d5a09fa243d90c2ddf3ea1a4f53df0 Mon Sep 17 00:00:00 2001 From: Aristotel Fani Date: Mon, 7 Aug 2023 17:35:20 -0400 Subject: [PATCH] Swap traffic stops by count using new charts --- .../Charts/TrafficStops/TrafficStops.js | 192 +++++------------- .../TrafficStops/TrafficStops.styled.js | 2 +- .../Charts/UseOfForce/UseOfForce.js | 12 +- .../Components/Elements/MonthRangePicker.js | 80 +------- .../src/Components/NewCharts/LineChart.js | 3 +- nc/urls.py | 4 + nc/views.py | 130 ++++++++++-- 7 files changed, 178 insertions(+), 245 deletions(-) diff --git a/frontend/src/Components/Charts/TrafficStops/TrafficStops.js b/frontend/src/Components/Charts/TrafficStops/TrafficStops.js index f124411d..ba3d24b1 100644 --- a/frontend/src/Components/Charts/TrafficStops/TrafficStops.js +++ b/frontend/src/Components/Charts/TrafficStops/TrafficStops.js @@ -15,7 +15,6 @@ import { reduceFullDataset, calculatePercentage, calculateYearTotal, - filterSinglePurpose, buildStackedBarData, STATIC_LEGEND_KEYS, YEARS_DEFAULT, @@ -35,16 +34,12 @@ import useMetaTags from '../../../Hooks/useMetaTags'; import useTableModal from '../../../Hooks/useTableModal'; // Children -import Line from '../ChartPrimitives/Line'; import StackedBar from '../ChartPrimitives/StackedBar'; import Legend from '../ChartSections/Legend/Legend'; import ChartHeader from '../ChartSections/ChartHeader'; import DataSubsetPicker from '../ChartSections/DataSubsetPicker/DataSubsetPicker'; -import toTitleCase from '../../../util/toTitleCase'; import useOfficerId from '../../../Hooks/useOfficerId'; import MonthRangePicker from '../../Elements/MonthRangePicker'; -import mapDatasetKeyToEndpoint from '../../../Services/endpoints'; -import range from 'lodash.range'; import LineChart from '../../NewCharts/LineChart'; import axios from '../../../Services/Axios'; import NewModal from '../../NewCharts/NewModal'; @@ -62,12 +57,8 @@ function TrafficStops(props) { const officerId = useOfficerId(); const [stopsChartState] = useDataset(agencyId, STOPS); - const [reasonChartState] = useDataset(agencyId, STOPS_BY_REASON); const [pickerActive, setPickerActive] = useState(null); - const [pickerXAxis, setPickerXAxis] = useState(null); - const [forcePickerRerender, setForcePickerRerender] = useState(false); - const [reasonChartYearSet, setReasonChartYearSet] = useState(reasonChartState.yearSet); const [year, setYear] = useState(YEARS_DEFAULT); @@ -92,9 +83,6 @@ function TrafficStops(props) { */ () => STATIC_LEGEND_KEYS.map((k) => ({ ...k })) ); - const [countEthnicGroups, setCountEthnicGroups] = useState(() => - STATIC_LEGEND_KEYS.map((k) => ({ ...k })) - ); const [stopPurposeEthnicGroups, setStopPurposeEthnicGroups] = useState(() => STATIC_LEGEND_KEYS.map((k) => ({ ...k })) ); @@ -116,8 +104,6 @@ function TrafficStops(props) { ], }); - const [byCountLineData, setByCountLineData] = useState([]); - const renderMetaTags = useMetaTags(); const [renderTableModal, { openModal }] = useTableModal(); @@ -205,6 +191,41 @@ function TrafficStops(props) { const [showZoomedPieChart, setShowZoomedPieChart] = useState(false); const zoomedPieCharRef = useRef(null); + const [trafficStopsByCountRange, setTrafficStopsByCountRange] = useState(null); + const [trafficStopsByCountPurpose, setTrafficStopsByCountPurpose] = useState(0); + const [trafficStopsByCount, setTrafficStopsByCount] = useState({ + labels: [], + datasets: [], + }); + + // Build Stops By Count + useEffect(() => { + const params = []; + if (trafficStopsByCountRange !== null) { + const _from = `${trafficStopsByCountRange.from.year}-${trafficStopsByCountRange.from.month + .toString() + .padStart(2, 0)}-01`; + const _to = `${trafficStopsByCountRange.to.year}-${trafficStopsByCountRange.to.month + .toString() + .padStart(2, 0)}-01`; + params.push({ param: 'from', val: _from }); + params.push({ param: 'to', val: _to }); + } + if (trafficStopsByCountPurpose !== 0) { + params.push({ param: 'purpose', val: trafficStopsByCountPurpose }); + } + + const urlParams = params.map((p) => `${p.param}=${p.val}`).join('&'); + const url = `/api/agency/${agencyId}/stops-by-count/?${urlParams}`; + + axios + .get(url) + .then((res) => { + setTrafficStopsByCount(res.data); + }) + .catch((err) => console.log(err)); + }, [trafficStopsByCountPurpose, trafficStopsByCountRange]); + // Build Stop Purpose Groups useEffect(() => { axios @@ -276,54 +297,6 @@ function TrafficStops(props) { } }, [stopsChartState.data[STOPS], year]); - // Build data for Stops By Count line chart ("All") - useEffect(() => { - const data = reasonChartState.data[STOPS]; - if (data && purpose === PURPOSE_DEFAULT) { - const derivedData = countEthnicGroups - .filter((g) => g.selected) - .map((r) => { - const race = r.value; - const rGroup = { - id: race, - color: theme.colors.ethnicGroup[race], - }; - rGroup.data = data.map((d) => ({ - displayName: toTitleCase(race), - // eslint-disable-next-line no-nested-ternary - x: pickerActive !== null ? (pickerXAxis === 'Month' ? d.date : d.year) : d.year, - y: d[race], - })); - return rGroup; - }); - setByCountLineData(derivedData); - } - }, [reasonChartState.data[STOPS], purpose, countEthnicGroups, pickerActive]); - - // Build data for Stops By Count line chart (single purpose) - useEffect(() => { - const data = reasonChartState.data[STOPS_BY_REASON]?.stops; - if (data && purpose !== PURPOSE_DEFAULT) { - const purposeData = filterSinglePurpose(data, purpose); - const derivedData = countEthnicGroups - .filter((g) => g.selected) - .map((r) => { - const race = r.value; - return { - id: race, - color: theme.colors.ethnicGroup[race], - data: purposeData.map((d) => ({ - displayName: toTitleCase(race), - // eslint-disable-next-line no-nested-ternary - x: pickerActive !== null ? (pickerXAxis === 'Month' ? d.date : d.year) : d.year, - y: d[race], - })), - }; - }); - setByCountLineData(derivedData); - } - }, [reasonChartState.data[STOPS_BY_REASON], purpose, countEthnicGroups, pickerActive]); - /* INTERACTIONS */ // Handle year dropdown state const handleYearSelect = (y) => { @@ -332,20 +305,10 @@ function TrafficStops(props) { }; // Handle stop purpose dropdown state - const handleStopPurposeSelect = async (p) => { + const handleStopPurposeSelect = (p, i) => { if (p === purpose) return; - if (p === PURPOSE_DEFAULT) { - setPickerActive(null); - // Reset the chart data - const getEndpoint = mapDatasetKeyToEndpoint(STOPS); - const { data } = await axios.get(getEndpoint(agencyId)); - reasonChartState.data[STOPS] = data; - setReasonChartYearSet(range(2002, new Date().getFullYear() + 1, 2)); - setForcePickerRerender(false); - } else if (pickerActive !== null) { - setForcePickerRerender(true); - } setPurpose(p); + setTrafficStopsByCountPurpose(i); }; // Handle stops by percentage legend interactions @@ -358,16 +321,6 @@ function TrafficStops(props) { setPercentageEthnicGroups(updatedGroups); }; - // Handle stops by count legend interactions - const handleCountKeySelected = (ethnicGroup) => { - const groupIndex = countEthnicGroups.indexOf( - countEthnicGroups.find((g) => g.value === ethnicGroup.value) - ); - const updatedGroups = [...countEthnicGroups]; - updatedGroups[groupIndex].selected = !updatedGroups[groupIndex].selected; - setCountEthnicGroups(updatedGroups); - }; - // Handle stops grouped by purpose legend interactions const handleStopPurposeKeySelected = (ethnicGroup) => { const groupIndex = stopPurposeEthnicGroups.indexOf( @@ -479,40 +432,11 @@ function TrafficStops(props) { }; const updateStopsByCount = (val) => { - setReasonChartYearSet(val.yearRange); - if (purpose !== PURPOSE_DEFAULT) { - reasonChartState.data[STOPS_BY_REASON] = val.data; - } else { - reasonChartState.data[STOPS] = val.data; - } - setPickerXAxis(val.xAxis); - setPickerActive((oldVal) => (oldVal === null ? true : !oldVal)); + setTrafficStopsByCountRange(val); }; - - const lineAxisFormat = (t) => { - if (pickerActive !== null) { - if (pickerXAxis === 'Month') { - if (typeof t === 'string') { - // Month label is YYYY-MM - const month = new Date(t).getMonth() + 1; - let datasetLength; - if (purpose !== PURPOSE_DEFAULT) { - datasetLength = reasonChartState.data[STOPS_BY_REASON]?.stops.length; - } else { - datasetLength = reasonChartState.data[STOPS].length; - } - if (datasetLength > 10 && datasetLength <= 15) { - return month % 2 === 0 ? t : null; - } - if (datasetLength > 15) { - return month % 3 === 0 ? t : null; - } - } - - return t; - } - } - return t % 2 === 0 ? t : null; + const closeStopsByCountRange = () => { + setPickerActive(null); + setTrafficStopsByCountRange(null); }; const handleChange = (nextChecked) => { @@ -679,18 +603,15 @@ function TrafficStops(props) {

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

- - `${t}`, - }} - xAxisLabel={pickerActive !== null ? pickerXAxis : 'Year'} - /> - + + + + + setPickerActive(null)} + onClosePicker={closeStopsByCountRange} /> - - - diff --git a/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js b/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js index 690e1f28..756803eb 100644 --- a/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js +++ b/frontend/src/Components/Charts/TrafficStops/TrafficStops.styled.js @@ -8,7 +8,7 @@ export const LineWrapper = styled.div` display: ${(props) => (props.visible ? 'flex' : 'none')}; flex-direction: row; flex-wrap: no-wrap; - width: 85%%; + width: 85%; margin: 0 auto; justify-content: space-evenly; diff --git a/frontend/src/Components/Charts/UseOfForce/UseOfForce.js b/frontend/src/Components/Charts/UseOfForce/UseOfForce.js index 11ef8b3d..9741cf17 100644 --- a/frontend/src/Components/Charts/UseOfForce/UseOfForce.js +++ b/frontend/src/Components/Charts/UseOfForce/UseOfForce.js @@ -191,12 +191,12 @@ function UseOfForce(props) { options={[YEARS_DEFAULT].concat(chartState.yearRange)} dropUp /> - setPickerActive(null)} - /> + {/* setPickerActive(null)} */} + {/* /> */} diff --git a/frontend/src/Components/Elements/MonthRangePicker.js b/frontend/src/Components/Elements/MonthRangePicker.js index 764c2497..8e89db4d 100644 --- a/frontend/src/Components/Elements/MonthRangePicker.js +++ b/frontend/src/Components/Elements/MonthRangePicker.js @@ -2,46 +2,18 @@ import Button from './Button'; import * as ChartHeaderStyles from '../Charts/ChartSections/ChartHeader.styled'; import { ICONS } from '../../img/icons/Icon'; import React, { forwardRef, useEffect, useState } from 'react'; -import mapDatasetKeyToEndpoint from '../../Services/endpoints'; -import axios from '../../Services/Axios'; -import { - CONTRABAND_HIT_RATE, - LIKELIHOOD_OF_SEARCH, - SEARCHES, - SEARCHES_BY_TYPE, - STOPS, - STOPS_BY_REASON, - USE_OF_FORCE, -} from '../../Hooks/useDataset'; + import { useTheme } from 'styled-components'; -import range from 'lodash.range'; import DatePicker from 'react-datepicker'; import { getRangeValues } from '../../util/range'; -const mapDataSetToEnum = { - STOPS, - SEARCHES, - STOPS_BY_REASON, - SEARCHES_BY_TYPE, - USE_OF_FORCE, - CONTRABAND_HIT_RATE, - LIKELIHOOD_OF_SEARCH, -}; - const MonthPickerButton = forwardRef(({ value, onClick }, ref) => ( )); -export default function MonthRangePicker({ - agencyId, - dataSet, - deactivatePicker, - forcePickerRerender, - onChange, - onClosePicker, -}) { +export default function MonthRangePicker({ deactivatePicker, onChange, onClosePicker }) { const theme = useTheme(); const startYear = new Date().setFullYear(2000, 1, 1); const [showDateRangePicker, setShowDateRangePicker] = useState(false); @@ -58,19 +30,6 @@ export default function MonthRangePicker({ } }, [deactivatePicker]); - useEffect(() => { - const rerenderData = async () => { - const rangeVal = { - from: { month: startDate.getMonth() + 1, year: startDate.getFullYear() }, - to: { month: endDate.getMonth() + 1, year: endDate.getFullYear() }, - }; - await updateDatePicker(rangeVal); - }; - if (forcePickerRerender) { - rerenderData().catch((err) => console.log(err)); - } - }, [forcePickerRerender]); - const showDatePicker = () => { const rangeValues = getRangeValues(); setStartDate(new Date().setFullYear(rangeValues.from.year, 1)); @@ -85,41 +44,10 @@ export default function MonthRangePicker({ setEndDate(new Date()); setMinDate(null); - await updateDatePicker(rangeValues); + onChange(null); onClosePicker(); }; - const updateDatePicker = async (rangeVal) => { - let tableDS = mapDataSetToEnum[dataSet]; - if (Array.isArray(dataSet)) { - tableDS = mapDataSetToEnum[dataSet[1]]; - } - const getEndpoint = mapDatasetKeyToEndpoint(tableDS); - const _from = `${rangeVal.from.year}-${rangeVal.from.month.toString().padStart(2, 0)}-01`; - const _to = `${rangeVal.to.year}-${rangeVal.to.month.toString().padStart(2, 0)}-01`; - const url = `${getEndpoint(agencyId)}?from=${_from}&to=${_to}`; - - const fromDate = new Date(_from); - fromDate.setDate(fromDate.getDate() + 1); // To prevent getting last month's date in new year - fromDate.toLocaleString('en-US', { timeZone: 'America/New_York' }); - const toDate = new Date(_to); - toDate.setDate(toDate.getDate() + 1); // To prevent getting last month's date in new year - toDate.toLocaleString('en-US', { timeZone: 'America/New_York' }); - - const diffYears = toDate.getFullYear() - fromDate.getFullYear(); - let xAxis = 'Year'; - const yearRange = range(rangeVal.from.year, rangeVal.to.year + 1, 1); - if (diffYears < 3) { - xAxis = 'Month'; - } - try { - const { data } = await axios.get(url); - onChange({ data, xAxis, yearRange }); - } catch (err) { - console.log(err); - } - }; - const onDateRangeChange = async (dates) => { // eslint-disable-next-line prefer-const let [start, end] = dates; @@ -143,7 +71,7 @@ export default function MonthRangePicker({ from: { month: start.getMonth() + 1, year: start.getFullYear() }, to: { month: end.getMonth() + 1, year: end.getFullYear() }, }; - await updateDatePicker(rangeVal); + onChange(rangeVal); } }; diff --git a/frontend/src/Components/NewCharts/LineChart.js b/frontend/src/Components/NewCharts/LineChart.js index 28c2389e..bb714e42 100644 --- a/frontend/src/Components/NewCharts/LineChart.js +++ b/frontend/src/Components/NewCharts/LineChart.js @@ -27,6 +27,7 @@ export default function LineChart({ yAxisMax = null, yAxisShowLabels = true, displayStopPurposeTooltips = false, + showLegendOnBottom = true, }) { const options = { responsive: true, @@ -38,7 +39,7 @@ export default function LineChart({ plugins: { legend: { display: displayLegend, - position: 'top', + position: showLegendOnBottom ? 'bottom' : 'top', onHover(event, legendItem) { if (displayStopPurposeTooltips) { setTooltipText(tooltipLanguage(legendItem.text)); diff --git a/nc/urls.py b/nc/urls.py index 43611c7b..11eb9194 100755 --- a/nc/urls.py +++ b/nc/urls.py @@ -15,6 +15,10 @@ 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//stops-by-count/", + views.AgencyTrafficStopsByCountView.as_view(), + ), path( "api/agency//stop-purpose-groups/", views.AgencyStopPurposeGroupView.as_view(), diff --git a/nc/views.py b/nc/views.py index 49cfb344..5d1ad53e 100644 --- a/nc/views.py +++ b/nc/views.py @@ -79,6 +79,26 @@ class QueryKeyConstructor(DefaultObjectKeyConstructor): query_cache_key_func = QueryKeyConstructor() +def get_date_range(request): + # Only filter is from and to values are found and are valid + date_precision = "year" + date_range = Q() + _from_date = request.query_params.get("from", None) + _to_date = request.query_params.get("to", None) + if _from_date and _to_date: + from_date = datetime.datetime.strptime(_from_date, "%Y-%m-%d") + to_date = datetime.datetime.strptime(_to_date, "%Y-%m-%d") + if from_date and to_date: + delta = relativedelta.relativedelta(to_date, from_date) + if delta.years < 3: + date_precision = "month" + 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 + + class AgencyViewSet(viewsets.ReadOnlyModelViewSet): queryset = Agency.objects.all() serializer_class = serializers.AgencySerializer @@ -92,25 +112,6 @@ def get_stopsummary_qs(self, agency): qs = qs.filter(agency=agency) return qs - def get_date_range(self): - # Only filter is from and to values are found and are valid - date_precision = "year" - date_range = Q() - _from_date = self.request.query_params.get("from", None) - _to_date = self.request.query_params.get("to", None) - if _from_date and _to_date: - from_date = datetime.datetime.strptime(_from_date, "%Y-%m-%d") - to_date = datetime.datetime.strptime(_to_date, "%Y-%m-%d") - if from_date and to_date: - delta = relativedelta.relativedelta(to_date, from_date) - if delta.years < 3: - date_precision = "month" - 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 - def query(self, results, group_by, filter_=None): qs = self.get_stopsummary_qs(agency=self.get_object()) # filter down by officer if supplied @@ -121,7 +122,7 @@ def query(self, results, group_by, filter_=None): qs = qs.filter(filter_) group_by_tuple = group_by - date_precision, date_range = self.get_date_range() + date_precision, date_range = get_date_range(self.request) qs = qs.filter(date_range) if date_precision == "year": qs = qs.annotate(year=ExtractYear("date")) @@ -373,6 +374,95 @@ def post(self, request): return Response(data=serializer.errors, status=400) +# def get_values(df, years, col, purpose=None): +# if purpose: +# if col in df[purpose]: +# return list(df[purpose][col].values) +# elif purpose == "All": +# pass +# +# return [0] * len(years) + + +class AgencyTrafficStopsByCountView(APIView): + def build_response(self, df, x_range, purpose=None): + def get_values(race): + if purpose: + if race in df[purpose]: + return list(df[purpose][race].values) + elif purpose is None: + return list(df[race].values) + + return [0] * len(x_range) + + return { + "labels": x_range, + "datasets": [ + { + "label": "White", + "data": get_values("White"), + "borderColor": "#02bcbb", + "backgroundColor": "#80d9d8", + }, + { + "label": "Black", + "data": get_values("Black"), + "borderColor": "#8879fc", + "backgroundColor": "#beb4fa", + }, + { + "label": "Hispanic", + "data": get_values("Hispanic"), + "borderColor": "#9c0f2e", + "backgroundColor": "#ca8794", + }, + { + "label": "Asian", + "data": get_values("Asian"), + "borderColor": "#ffe066", + "backgroundColor": "#ffeeb2", + }, + { + "label": "Native American", + "data": get_values("Native American"), + "borderColor": "#0c3a66", + "backgroundColor": "#8598ac", + }, + { + "label": "Other", + "data": get_values("Other"), + "borderColor": "#9e7b9b", + "backgroundColor": "#cab6c7", + }, + ], + } + + def get(self, request, agency_id): + date_precision, date_range = get_date_range(request) + qs = StopSummary.objects.filter(agency_id=agency_id).filter(date_range) + + if date_precision == "year": + qs = qs.annotate(year=ExtractYear("date")) + else: + date_precision = "date" + + qs_df_cols = ["driver_race_comb"] + stop_purpose = int(request.query_params.get("purpose", 0)) + if stop_purpose != 0: + qs_df_cols.insert(0, "stop_purpose") + qs_values = [date_precision] + qs_df_cols + + qs = qs.values(*qs_values).annotate(count=Sum("count")).order_by(date_precision) + df = pd.DataFrame(qs) + unique_x_range = df[date_precision].unique() + pivot_df = df.pivot(index=date_precision, columns=qs_df_cols, values="count").fillna( + value=0 + ) + df = pd.DataFrame(pivot_df) + data = self.build_response(df, unique_x_range, stop_purpose if stop_purpose != 0 else None) + return Response(data=data, status=200) + + class AgencyStopPurposeGroupView(APIView): def get(self, request, agency_id): qs = (