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

Update Use of Force graph to use new library #255

Merged
merged 3 commits into from
Jan 17, 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
152 changes: 56 additions & 96 deletions frontend/src/Components/Charts/UseOfForce/UseOfForce.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import React, { useState, useEffect } from 'react';
import UseOfForceStyled from './UseOfForce.styled';
import * as S from '../ChartSections/ChartsCommon.styled';
import { useTheme } from 'styled-components';

// Util
import {
YEARS_DEFAULT,
STATIC_LEGEND_KEYS,
RACES,
calculatePercentage,
calculateYearTotal,
Expand All @@ -23,29 +21,22 @@ import useTableModal from '../../../Hooks/useTableModal';
// Children
import { P } from '../../../styles/StyledComponents/Typography';
import ChartHeader from '../ChartSections/ChartHeader';
import Legend from '../ChartSections/Legend/Legend';
import DataSubsetPicker from '../ChartSections/DataSubsetPicker/DataSubsetPicker';
import useOfficerId from '../../../Hooks/useOfficerId';
import GroupedBar from '../ChartPrimitives/GroupedBar';
import PieChart from '../../NewCharts/PieChart';
import { pieChartConfig, pieChartLabels } from '../../../util/setChartColors';
import VerticalBarChart from '../../NewCharts/VerticalBarChart';
import axios from '../../../Services/Axios';

function UseOfForce(props) {
const { agencyId, showCompare } = props;
const officerId = useOfficerId();
const theme = useTheme();

const [chartState] = useDataset(agencyId, USE_OF_FORCE);

const [year, setYear] = useState(YEARS_DEFAULT);
const [pickerActive] = useState(null);
const [pickerXAxis] = useState(null);

const [ethnicGroupKeys, setEthnicGroupKeys] = useState(() =>
STATIC_LEGEND_KEYS.map((k) => ({ ...k }))
);

const [useOfForceData, setUseOfForceData] = useState([]);
const [useOfForceBarData, setUseOfForceBarData] = useState({ labels: [], datasets: [] });
const [useOfForcePieData, setUseOfForcePieData] = useState({
labels: pieChartLabels,
datasets: [
Expand All @@ -69,29 +60,18 @@ function UseOfForce(props) {
return '';
};

/* BUILD DATA */
// Bar chart data
useEffect(() => {
const data = chartState.data[USE_OF_FORCE];
if (data) {
const mappedData = ethnicGroupKeys
.filter((e) => e.selected)
.map((eg) => {
const ethnicGroup = eg.value;
return {
id: ethnicGroup,
color: theme.colors.ethnicGroup[ethnicGroup],
data: data.map((d) => ({
x: pickerXAxis === 'Month' ? d.date : d.year,
y: d[ethnicGroup],
ethnicGroup: eg.label,
color: theme.colors.ethnicGroup[ethnicGroup],
})),
};
});
setUseOfForceData(mappedData);
let url = `/api/agency/${agencyId}/use-of-force/`;
if (officerId !== null) {
url = `${url}?officer=${officerId}`;
}
}, [chartState.data[USE_OF_FORCE], ethnicGroupKeys]);
axios
.get(url)
.then((res) => {
setUseOfForceBarData(res.data);
})
.catch((err) => console.log(err));
}, []);

// Pie chart data
useEffect(() => {
Expand Down Expand Up @@ -133,38 +113,28 @@ function UseOfForce(props) {
setYear(y);
};
// Handle stops by percentage legend interactions
const handleGroupKeySelected = (ethnicGroup) => {
const groupIndex = ethnicGroupKeys.indexOf(
ethnicGroupKeys.find((g) => g.value === ethnicGroup.value)
);
const updatedGroups = [...ethnicGroupKeys];
updatedGroups[groupIndex].selected = !updatedGroups[groupIndex].selected;
setEthnicGroupKeys(updatedGroups);
};
const handleViewData = () => {
openModal(USE_OF_FORCE, TABLE_COLUMNS);
};

const lineAxisFormat = (t) => {
if (pickerActive) {
return t;
}
return t % 2 === 0 ? t : null;
};

const pieChartTitle = () => {
const chartModalTitle = (displayYear = true) => {
let subject = chartState.data[AGENCY_DETAILS].name;
if (subjectObserving() === 'officer') {
subject = `Officer ${officerId}`;
}
return `Use of Force for ${subject} ${
year === YEARS_DEFAULT ? `since ${chartState.yearRange.reverse()[0]}` : `in ${year}`
}`;
let yearOf = `since ${useOfForceBarData.labels[0]}`;
if (displayYear) {
yearOf = year === YEARS_DEFAULT ? `since ${useOfForceBarData.labels[0]}` : `in ${year}`;
}
return `Use of Force for ${subject} ${yearOf}`;
};

const getChartModalSubHeading = () => {
const yearSelected = year && year !== 'All' ? ` in ${year}` : '';
return `Shows the race/ethnic composition of drivers ${subjectObserving()}${yearSelected} reported using force against.`;
const getChartModalSubHeading = (displayYear = true) => {
let yearOf = '';
if (displayYear) {
yearOf = year && year !== 'All' ? ` in ${year}` : '';
}
return `Shows the race/ethnic composition of drivers ${subjectObserving()}${yearOf} reported using force against.`;
};

return (
Expand All @@ -180,48 +150,38 @@ function UseOfForce(props) {
</P>
</S.ChartDescription>
<S.ChartSubsection showCompare={showCompare}>
<S.LineSection>
<S.LineWrapper>
<GroupedBar
data={useOfForceData}
iTickFormat={lineAxisFormat}
iTickValues={chartState.yearSet}
loading={chartState.loading[USE_OF_FORCE]}
toolTipFontSize={16}
dAxisProps={{
tickFormat: (t) => `${t}`,
}}
/>
</S.LineWrapper>
<Legend
heading="Show on graph:"
keys={ethnicGroupKeys}
onKeySelect={handleGroupKeySelected}
showNonHispanic
/>
</S.LineSection>
<S.PieSection>
<S.PieWrapper>
<PieChart
data={useOfForcePieData}
displayLegend={false}
maintainAspectRatio
modalConfig={{
tableHeader: 'Use of Force',
tableSubheader: getChartModalSubHeading(),
agencyName: chartState.data[AGENCY_DETAILS].name,
chartTitle: pieChartTitle(),
}}
/>
</S.PieWrapper>
<DataSubsetPicker
label="Year"
value={year}
onChange={handleYearSelected}
options={[YEARS_DEFAULT].concat(chartState.yearRange)}
dropUp
<VerticalBarChart
title="Use Of Force"
data={useOfForceBarData}
modalConfig={{
tableHeader: 'Use of Force',
tableSubheader: getChartModalSubHeading(false),
agencyName: chartState.data[AGENCY_DETAILS].name,
chartTitle: chartModalTitle(false),
}}
/>
</S.ChartSubsection>
<S.ChartSubsection>
<S.PieWrapper>
<PieChart
data={useOfForcePieData}
displayLegend={false}
maintainAspectRatio
modalConfig={{
tableHeader: 'Use of Force',
tableSubheader: getChartModalSubHeading(),
agencyName: chartState.data[AGENCY_DETAILS].name,
chartTitle: chartModalTitle(),
}}
/>
</S.PieSection>
</S.PieWrapper>
<DataSubsetPicker
label="Year"
value={year}
onChange={handleYearSelected}
options={[YEARS_DEFAULT].concat(chartState.yearRange)}
dropUp
/>
</S.ChartSubsection>
</S.ChartSection>
</UseOfForceStyled>
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/Components/NewCharts/VerticalBarChart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Bar } from 'react-chartjs-2';
import DataLoading from '../Charts/ChartPrimitives/DataLoading';
import React, { useRef, useState } from 'react';
import ChartModal from './ChartModal';

export default function VerticalBarChart({
data,
title,
maintainAspectRatio = true,
tooltipTitleCallback = null,
tooltipLabelCallback = null,
modalConfig = {},
}) {
const options = {
responsive: true,
maintainAspectRatio,
onHover(evt, chartEl) {
// If there is a chart element found on hover, set the cursor to pointer
// to let users know they can view the modal
// eslint-disable-next-line no-param-reassign
evt.native.target.style.cursor = chartEl.length ? 'pointer' : 'default';
},
onClick(evt, activeEls) {
if (activeEls.length) {
setIsChartOpen(true);
}
},
plugins: {
title: {
display: true,
text: title,
},
legend: {
position: 'bottom',
},
tooltip: {
callbacks: {
title(context) {
if (tooltipTitleCallback) {
return tooltipTitleCallback(context);
}
return context.title;
},
label(context) {
if (tooltipLabelCallback) {
return tooltipLabelCallback(context);
}
return `${context.dataset.label}: ${context.formattedValue}`;
},
},
},
},
};

const [isChartOpen, setIsChartOpen] = useState(false);
const zoomedLineChartRef = useRef(null);

if (!data.labels.length) {
return <DataLoading />;
}

const whiteBackground = {
id: 'customVerticalBarCanvasBackgroundColor',
beforeDraw: (chart, args, config) => {
const { ctx } = chart;
ctx.save();
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = config.color || '#fff';
ctx.fillRect(0, 0, chart.width, chart.height);
ctx.restore();
},
};

const createModalOptions = (opts) => {
const modalOptions = JSON.parse(JSON.stringify(opts));
modalOptions.plugins.tooltip.enabled = true;
modalOptions.plugins.title = {
display: true,
text: modalConfig.chartTitle,
};
return modalOptions;
};

const barChartModalPlugins = [whiteBackground];
const barChartModalOptions = createModalOptions(options);

return (
<>
<Bar options={options} data={data} />
<ChartModal
isOpen={isChartOpen}
closeModal={() => setIsChartOpen(false)}
chartToPrintRef={zoomedLineChartRef}
chartTitle={modalConfig.chartTitle}
{...modalConfig}
>
<Bar
ref={zoomedLineChartRef}
data={data}
options={barChartModalOptions}
plugins={barChartModalPlugins}
/>
</ChartModal>
</>
);
}
1 change: 1 addition & 0 deletions nc/prime_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"nc:contraband-percentages-stop-purpose-groups",
"nc:contraband-percentages-grouped-stop-purpose",
"nc:contraband-percentages-grouped-stop-purpose-modal",
"nc:use-of-force",
)
DEFAULT_CUTOFF_SECS = 4

Expand Down
5 changes: 5 additions & 0 deletions nc/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@
views.AgencyContrabandStopGroupByPurposeModalView.as_view(),
name="contraband-percentages-grouped-stop-purpose-modal",
),
path(
"api/agency/<agency_id>/use-of-force/",
views.AgencyUseOfForceView.as_view(),
name="use-of-force",
),
]
Loading