Skip to content

Commit

Permalink
Convert stops by percentage to new graph library (#256)
Browse files Browse the repository at this point in the history
* Convert stops by percentage to new graph library

* remove unused util functions

* update prime cache urls
  • Loading branch information
Afani97 authored Jan 18, 2024
1 parent 72fc27e commit ddc98ef
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 118 deletions.
72 changes: 36 additions & 36 deletions frontend/src/Components/Charts/TrafficStops/TrafficStops.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
reduceFullDataset,
calculatePercentage,
calculateYearTotal,
buildStackedBarData,
STATIC_LEGEND_KEYS,
YEARS_DEFAULT,
PURPOSE_DEFAULT,
Expand All @@ -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';
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -90,7 +89,6 @@ function TrafficStops(props) {
STATIC_LEGEND_KEYS.map((k) => ({ ...k }))
);

const [byPercentageLineData, setByPercentageLineData] = useState([]);
const [byPercentagePieData, setByPercentagePieData] = useState({
labels: pieChartLabels,
datasets: [
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
<TrafficStopsStyled>
{/* Traffic Stops by Percentage */}
Expand All @@ -606,20 +608,18 @@ function TrafficStops(props) {
</S.ChartDescription>
<S.ChartSubsection showCompare={props.showCompare}>
<S.LineSection>
<S.LineWrapper>
<StackedBar
horizontal
data={byPercentageLineData}
tickValues={stopsChartState.yearSet}
loading={stopsChartState.loading[STOPS]}
yAxisLabel={(val) => `${val}%`}
/>
</S.LineWrapper>
<Legend
heading="Show on graph:"
keys={percentageEthnicGroups}
onKeySelect={handlePercentageKeySelected}
showNonHispanic
<VerticalBarChart
title="Traffic Stops By Percentage"
data={stopsByPercentageData}
stacked
disableLegend
tooltipLabelCallback={formatTooltipValue}
modalConfig={{
tableHeader: 'Traffic Stops By Percentage',
tableSubheader: `Shows the race/ethnic composition of drivers stopped by this ${subjectObserving()} over time.`,
agencyName: stopsChartState.data[AGENCY_DETAILS].name,
chartTitle: stopsByPercentageModalTitle(),
}}
/>
</S.LineSection>

Expand Down
82 changes: 0 additions & 82 deletions frontend/src/Components/Charts/chartUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/Components/NewCharts/VerticalBarChart.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export default function VerticalBarChart({
maintainAspectRatio = true,
tooltipTitleCallback = null,
tooltipLabelCallback = null,
stacked = false,
disableLegend = false,
modalConfig = {},
}) {
const options = {
Expand All @@ -25,6 +27,15 @@ export default function VerticalBarChart({
setIsChartOpen(true);
}
},
scales: {
x: {
stacked,
},
y: {
stacked,
max: stacked ? 1 : null,
},
},
plugins: {
title: {
display: true,
Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions nc/prime_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions nc/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<agency_id>/stops-by-percentage/",
views.AgencyTrafficStopsByPercentageView.as_view(),
name="stops-by-percentage",
),
path(
"api/agency/<agency_id>/stops-by-count/",
views.AgencyTrafficStopsByCountView.as_view(),
Expand Down
95 changes: 95 additions & 0 deletions nc/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit ddc98ef

Please sign in to comment.