diff --git a/frontend/src/Components/Charts/TrafficStops/TrafficStops.js b/frontend/src/Components/Charts/TrafficStops/TrafficStops.js index a11e0474..65590ff2 100644 --- a/frontend/src/Components/Charts/TrafficStops/TrafficStops.js +++ b/frontend/src/Components/Charts/TrafficStops/TrafficStops.js @@ -16,7 +16,6 @@ import { reduceFullDataset, calculatePercentage, calculateYearTotal, - buildStackedBarData, STATIC_LEGEND_KEYS, YEARS_DEFAULT, PURPOSE_DEFAULT, @@ -35,7 +34,6 @@ import useMetaTags from '../../../Hooks/useMetaTags'; import useTableModal from '../../../Hooks/useTableModal'; // Children -import StackedBar from '../ChartPrimitives/StackedBar'; import Legend from '../ChartSections/Legend/Legend'; import ChartHeader from '../ChartSections/ChartHeader'; import DataSubsetPicker from '../ChartSections/DataSubsetPicker/DataSubsetPicker'; @@ -49,6 +47,7 @@ import PieChart from '../../NewCharts/PieChart'; import Switch from 'react-switch'; import Checkbox from '../../Elements/Inputs/Checkbox'; import { pieChartConfig, pieChartLabels, pieColors } from '../../../util/setChartColors'; +import VerticalBarChart from '../../NewCharts/VerticalBarChart'; function TrafficStops(props) { const { agencyId } = props; @@ -70,7 +69,7 @@ function TrafficStops(props) { // Don't include Average as that's only used in the Search Rate graph. const stopTypes = STOP_TYPES.filter((st) => st !== 'Average'); - const [percentageEthnicGroups, setPercentageEthnicGroups] = useState( + const [percentageEthnicGroups] = useState( /* I sure wish I understood with certainty why this is necessary. Here's what I know: - Setting both of these states to STATIC_LEGEND_KEYS cause calling either setState function to mutate both states. @@ -90,7 +89,6 @@ function TrafficStops(props) { STATIC_LEGEND_KEYS.map((k) => ({ ...k })) ); - const [byPercentageLineData, setByPercentageLineData] = useState([]); const [byPercentagePieData, setByPercentagePieData] = useState({ labels: pieChartLabels, datasets: [ @@ -281,16 +279,20 @@ function TrafficStops(props) { .catch((err) => console.log(err)); }, []); - /* CALCULATE AND BUILD CHART DATA */ - // Build data for Stops by Percentage line chart + const [stopsByPercentageData, setStopsByPercentageData] = useState({ labels: [], datasets: [] }); + useEffect(() => { - const data = stopsChartState.data[STOPS]; - if (data && pickerActive === null) { - const filteredGroups = percentageEthnicGroups.filter((g) => g.selected).map((g) => g.value); - const derivedData = buildStackedBarData(data, filteredGroups, theme); - setByPercentageLineData(derivedData); + let url = `/api/agency/${agencyId}/stops-by-percentage/`; + if (officerId !== null) { + url = `${url}?officer=${officerId}`; } - }, [stopsChartState.data[STOPS], percentageEthnicGroups]); + axios + .get(url) + .then((res) => { + setStopsByPercentageData(res.data); + }) + .catch((err) => console.log(err)); + }, []); // Build data for Stops by Percentage pie chart useEffect(() => { @@ -332,16 +334,6 @@ function TrafficStops(props) { setTrafficStopsByCountPurpose(i); }; - // Handle stops by percentage legend interactions - const handlePercentageKeySelected = (ethnicGroup) => { - const groupIndex = percentageEthnicGroups.indexOf( - percentageEthnicGroups.find((g) => g.value === ethnicGroup.value) - ); - const updatedGroups = [...percentageEthnicGroups]; - updatedGroups[groupIndex].selected = !updatedGroups[groupIndex].selected; - setPercentageEthnicGroups(updatedGroups); - }; - // Handle stops grouped by purpose legend interactions const handleStopPurposeKeySelected = (ethnicGroup) => { const groupIndex = stopPurposeEthnicGroups.indexOf( @@ -587,6 +579,16 @@ function TrafficStops(props) { return `${title} by ${subject}${stopPurposeSelected} since ${trafficStopsByCount.labels[0]}`; }; + const formatTooltipValue = (ctx) => `${ctx.dataset.label}: ${(ctx.raw * 100).toFixed(2)}%`; + + const stopsByPercentageModalTitle = () => { + let subject = stopsChartState.data[AGENCY_DETAILS].name; + if (subjectObserving() === 'officer') { + subject = `Officer ${officerId}`; + } + return `Traffic Stops by Percentage for ${subject} since ${stopsByPercentageData.labels[0]}`; + }; + return ( {/* Traffic Stops by Percentage */} @@ -606,20 +608,18 @@ function TrafficStops(props) { - - `${val}%`} - /> - - diff --git a/frontend/src/Components/Charts/chartUtils.js b/frontend/src/Components/Charts/chartUtils.js index ea53f6b5..08a3456b 100644 --- a/frontend/src/Components/Charts/chartUtils.js +++ b/frontend/src/Components/Charts/chartUtils.js @@ -72,22 +72,6 @@ export function reduceYearsToTotal(data, ethnicGroup) { export function filterSinglePurpose(data, purpose) { return data.filter((d) => d.purpose === purpose); } - -export const filterDataBySearchType = (data, searchTypeFilter) => { - if (searchTypeFilter === SEARCH_TYPE_DEFAULT) return data; - return data.filter((d) => d.search_type === searchTypeFilter); -}; - -export const getQuantityForYear = (data, year, ethnicGroup) => { - if (data.length > 0) { - const statForYear = data.find((d) => d.year === year); - if (statForYear) { - return statForYear[ethnicGroup]; - } - } - return 0; -}; - /** * Given an Array of objects with shape { year, asian, black, etc. }, reduce to percentages of total by race. * provide Theme object to provide fill colors. @@ -125,72 +109,6 @@ export function reduceFullDatasetOnlyTotals(data, ethnicGroups) { return totals; } - -export function buildStackedBarData(data, filteredKeys, theme) { - const mappedData = []; - const yearTotals = {}; - data.forEach((row) => { - yearTotals[row.year] = calculateYearTotal(row, filteredKeys); - }); - - filteredKeys.forEach((ethnicGroup) => { - const groupSet = {}; - groupSet.id = toTitleCase(ethnicGroup); - groupSet.color = theme.colors.ethnicGroup[ethnicGroup]; - groupSet.data = data.map((datum) => ({ - x: datum.year, - y: calculatePercentage(datum[ethnicGroup], yearTotals[datum.year]), - displayName: toTitleCase(ethnicGroup), - color: theme.colors.ethnicGroup[ethnicGroup], - })); - mappedData.push(groupSet); - }); - return mappedData; -} - -export function getSearchRateForYearByGroup(searches, stops, year, ethnicGroup, filteredKeys) { - const searchesForYear = searches.find((s) => s.year === year); - const stopsForYear = stops.find((s) => s.year === year); - // Officers often have no results for a year. - if (!searchesForYear || !stopsForYear) return 0; - if (ethnicGroup === AVERAGE_KEY) { - let totalSearches = 0; - let totalStops = 0; - filteredKeys.forEach((eg) => { - const g = eg.value; - if (g === AVERAGE_KEY) return; - totalSearches += searchesForYear[g]; - totalStops += stopsForYear[g]; - }); - return calculatePercentage(totalSearches, totalStops); - } - const searchesForGroup = searchesForYear ? searchesForYear[ethnicGroup] : 0; - const stopsForGroup = stopsForYear ? stopsForYear[ethnicGroup] : 0; - return calculatePercentage(searchesForGroup, stopsForGroup); -} - -export const reduceStopReasonsByEthnicity = (data, yearsSet, ethnicGroup, searchTypeFilter) => - yearsSet.map((year) => { - const tick = {}; - tick.x = year; - tick.symbol = 'circle'; - tick.displayName = toTitleCase(ethnicGroup); - if (searchTypeFilter === SEARCH_TYPE_DEFAULT) { - const yrSet = data.filter((d) => d.year === year); - // No searches this year - if (yrSet.length === 0) tick.y = 0; - else { - tick.y = yrSet.reduce((acc, curr) => ({ - [ethnicGroup]: acc[ethnicGroup] + curr[ethnicGroup], - }))[ethnicGroup]; - } - } else { - const yearData = data.find((d) => d.year === year); - tick.y = yearData ? yearData[ethnicGroup] : 0; - } - return tick; - }); - export const reduceEthnicityByYears = (data, yearsSet, ethnicGroups = RACES) => { const yearData = []; yearsSet.forEach((yr) => { diff --git a/frontend/src/Components/NewCharts/VerticalBarChart.js b/frontend/src/Components/NewCharts/VerticalBarChart.js index 6ecec814..48c52911 100644 --- a/frontend/src/Components/NewCharts/VerticalBarChart.js +++ b/frontend/src/Components/NewCharts/VerticalBarChart.js @@ -9,6 +9,8 @@ export default function VerticalBarChart({ maintainAspectRatio = true, tooltipTitleCallback = null, tooltipLabelCallback = null, + stacked = false, + disableLegend = false, modalConfig = {}, }) { const options = { @@ -25,6 +27,15 @@ export default function VerticalBarChart({ setIsChartOpen(true); } }, + scales: { + x: { + stacked, + }, + y: { + stacked, + max: stacked ? 1 : null, + }, + }, plugins: { title: { display: true, @@ -52,6 +63,17 @@ export default function VerticalBarChart({ }, }; + if (disableLegend) { + options.plugins.legend.onClick = null; + } + if (stacked) { + options.scales.y.ticks = { + format: { + style: 'percent', + }, + }; + } + const [isChartOpen, setIsChartOpen] = useState(false); const zoomedLineChartRef = useRef(null); diff --git a/nc/prime_cache.py b/nc/prime_cache.py index d1b5dc00..f1d0a57e 100755 --- a/nc/prime_cache.py +++ b/nc/prime_cache.py @@ -18,6 +18,7 @@ "nc:agency-api-searches-by-type", "nc:agency-api-contraband-hit-rate", "nc:agency-api-use-of-force", + "nc:stops-by-percentage", "nc:stops-by-count", "nc:stop-purpose-groups", "nc:stops-grouped-by-purpose", diff --git a/nc/urls.py b/nc/urls.py index f52080d0..71af2e01 100755 --- a/nc/urls.py +++ b/nc/urls.py @@ -15,6 +15,11 @@ 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-percentage/", + views.AgencyTrafficStopsByPercentageView.as_view(), + name="stops-by-percentage", + ), path( "api/agency//stops-by-count/", views.AgencyTrafficStopsByCountView.as_view(), diff --git a/nc/views.py b/nc/views.py index fb02fd76..39f40e9f 100644 --- a/nc/views.py +++ b/nc/views.py @@ -383,6 +383,101 @@ def post(self, request): return Response(data=serializer.errors, status=400) +class AgencyTrafficStopsByPercentageView(APIView): + def build_response(self, df, x_range): + def get_values(race): + if race in df: + 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): + stop_qs = StopSummary.objects.all().annotate(year=ExtractYear("date")) + + agency_id = int(agency_id) + if agency_id != -1: + stop_qs = stop_qs.filter(agency_id=agency_id) + + officer = request.query_params.get("officer", None) + if officer: + stop_qs = stop_qs.filter(officer_id=officer) + + date_precision = "year" + qs_values = [date_precision, "driver_race_comb"] + + stop_qs = stop_qs.values(*qs_values).annotate(count=Sum("count")).order_by(date_precision) + + if stop_qs.count() == 0: + return Response(data={"labels": [], "datasets": []}, status=200) + + stops_df = pd.DataFrame(stop_qs) + + unique_x_range = stops_df[date_precision].unique() + + stop_pivot_df = stops_df.pivot( + index=date_precision, columns="driver_race_comb", values="count" + ).fillna(value=0) + stops_df = pd.DataFrame(stop_pivot_df) + + columns = ["White", "Black", "Hispanic", "Asian", "Native American", "Other"] + for year in unique_x_range: + total_stops_for_year = sum( + float(stops_df[c][year]) for c in columns if c in stops_df and year in stops_df[c] + ) + for col in columns: + if col not in stops_df or year not in stops_df[col]: + continue + try: + stops_df[col][year] = float(stops_df[col][year] / total_stops_for_year) + except ZeroDivisionError: + stops_df[col][year] = 0 + + data = self.build_response(stops_df, unique_x_range) + return Response(data=data, status=200) + + class AgencyTrafficStopsByCountView(APIView): def build_response(self, df, x_range, purpose=None): def get_values(race):