Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert stops by percentage to new graph library #256

Merged
merged 3 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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