diff --git a/spotlight-client/package.json b/spotlight-client/package.json
index 49aa1217..3acee5b8 100644
--- a/spotlight-client/package.json
+++ b/spotlight-client/package.json
@@ -43,6 +43,7 @@
"@types/react-simple-maps": "^1.0.3",
"@types/styled-components": "^5.1.5",
"@types/topojson": "^3.2.2",
+ "@vx/pattern": "^0.0.199",
"@w11r/use-breakpoint": "^1.8.0",
"assert-never": "^1.2.1",
"body-scroll-lock": "^3.1.5",
diff --git a/spotlight-client/src/Notes/Notes.tsx b/spotlight-client/src/Notes/Notes.tsx
index 134fb5c6..3af5bbc5 100644
--- a/spotlight-client/src/Notes/Notes.tsx
+++ b/spotlight-client/src/Notes/Notes.tsx
@@ -25,9 +25,8 @@ const Wrapper = styled.ol`
font-size: ${rem(13)};
font-weight: 500;
line-height: 1.7;
- list-style: decimal outside;
+ list-style: none;
margin-top: ${rem(40)};
- padding-left: 1em;
`;
const Item = styled.li`
diff --git a/spotlight-client/src/charts/BarChartTrellis.test.tsx b/spotlight-client/src/charts/BarChartTrellis.test.tsx
index df45c904..a0069bc0 100644
--- a/spotlight-client/src/charts/BarChartTrellis.test.tsx
+++ b/spotlight-client/src/charts/BarChartTrellis.test.tsx
@@ -27,8 +27,8 @@ const testData = [
{
label: "Group 1",
records: [
- { label: "Category A", color: "red", value: 30, pct: 0.3 },
- { label: "Category B", color: "blue", value: 70, pct: 0.7 },
+ { label: "Category A", color: "red", value: 300, pct: 0.3 },
+ { label: "Category B", color: "blue", value: 700, pct: 0.7 },
],
},
{
@@ -54,6 +54,7 @@ test("renders charts", () => {
).toHaveStyle("fill: red");
expect(
screen.getByRole("img", { name: "Category A bar value 40%" })
+ // if value is less than certain threshold (e.g. n=100), then the bar is rendered as a hatch
).toHaveStyle("fill: red");
expect(
screen.getByRole("img", { name: "Category B bar value 70%" })
diff --git a/spotlight-client/src/charts/BarChartTrellis.tsx b/spotlight-client/src/charts/BarChartTrellis.tsx
index 51fa3bf0..67a1f579 100644
--- a/spotlight-client/src/charts/BarChartTrellis.tsx
+++ b/spotlight-client/src/charts/BarChartTrellis.tsx
@@ -23,10 +23,11 @@ import ResponsiveTooltipController, {
ResponsiveTooltipControllerProps,
} from "./ResponsiveTooltipController";
import { formatAsPct } from "../utils";
-import { highlightFade } from "./utils";
+import { isSmallData, highlightFade } from "./utils";
import { animation } from "../UiLibrary";
import { CategoricalChartRecord, CommonDataPoint } from "./types";
import MeasureWidth from "../MeasureWidth";
+import { useCreateHatchDefs } from "./useCreateHatchDefs";
export const singleChartHeight = 300;
@@ -71,6 +72,7 @@ export function BarChartTrellis({
getTooltipProps,
}: BarChartTrellisProps): React.ReactElement {
const [highlightedLabel, setHighlightedLabel] = useState();
+ const { getHatchDefs, generateHatchFill } = useCreateHatchDefs();
// ResponsiveTooltipController expects this to be a stable reference
const setHighlighted = useCallback(
@@ -84,73 +86,85 @@ export function BarChartTrellis({
{width === 0
? null
- : data.map(({ label, records: chartData }, index) => (
-
- {
+ const hatchDefs = getHatchDefs(chartData);
+
+ return (
+
+
+ {barAxisLabel}
+
+ ) : undefined
+ }
+ baseMarkProps={{
+ transitionDuration: { fill: animation.defaultDuration },
+ }}
+ data={chartData}
+ margin={MARGIN}
+ oAccessor="label"
+ // @ts-expect-error Semiotic types can't handle a styled component here but it's fine
+ oLabel={(barLabel) => (
+
- {barAxisLabel}
-
- ) : undefined
- }
- baseMarkProps={{
- transitionDuration: { fill: animation.defaultDuration },
- }}
- data={chartData}
- margin={MARGIN}
- oAccessor="label"
- // @ts-expect-error Semiotic types can't handle a styled component here but it's fine
- oLabel={(barLabel) => (
-
+ )}
+ oPadding={2}
+ rAccessor="pct"
+ rExtent={[0, 1]}
+ size={[width, singleChartHeight]}
+ style={(d: CommonDataPoint) => {
+ if (isSmallData(chartData)) {
+ return {
+ fill: generateHatchFill(d.label, highlightedLabel),
+ };
}
- >
- {formatBarLabel(barLabel as string)}
-
- )}
- oPadding={2}
- rAccessor="pct"
- rExtent={[0, 1]}
- size={[width, singleChartHeight]}
- style={(d: CommonDataPoint) => ({
- fill:
- highlightedLabel && highlightedLabel !== d.label
- ? highlightFade(d.color)
- : d.color,
- })}
- // Semiotic centers titles by default; this x offset will align left
- title={{label}}
- type="bar"
- />
-
- ))}
+ return {
+ fill:
+ highlightedLabel && highlightedLabel !== d.label
+ ? highlightFade(d.color)
+ : d.color,
+ };
+ }}
+ // Semiotic centers titles by default; this x offset will align left
+ title={{label}}
+ type="bar"
+ additionalDefs={hatchDefs}
+ />
+
+ );
+ })}
)}
diff --git a/spotlight-client/src/charts/BubbleChart.test.tsx b/spotlight-client/src/charts/BubbleChart.test.tsx
index d2d9037a..a3733dfe 100644
--- a/spotlight-client/src/charts/BubbleChart.test.tsx
+++ b/spotlight-client/src/charts/BubbleChart.test.tsx
@@ -25,7 +25,7 @@ jest.mock("../MeasureWidth/MeasureWidth");
const testData = [
{ label: "thing 1", color: "red", value: 10, pct: 0.1086956522 },
{ label: "thing 2", color: "blue", value: 50, pct: 0.5434782609 },
- { label: "thing 3", color: "green", value: 32, pct: 0.347826087 },
+ { label: "thing 3", color: "green", value: 320, pct: 0.347826087 },
];
test("renders bubbles for data", () => {
diff --git a/spotlight-client/src/charts/BubbleChart.tsx b/spotlight-client/src/charts/BubbleChart.tsx
index d0098f6d..dd997f90 100644
--- a/spotlight-client/src/charts/BubbleChart.tsx
+++ b/spotlight-client/src/charts/BubbleChart.tsx
@@ -32,10 +32,11 @@ import styled from "styled-components/macro";
import ColorLegend from "./ColorLegend";
import ResponsiveTooltipController from "./ResponsiveTooltipController";
import { formatAsPct } from "../utils";
-import { useHighlightedItem, highlightFade } from "./utils";
+import { useHighlightedItem, highlightFade, isSmallData } from "./utils";
import { CategoricalChartRecord } from "./types";
import { animation, colors, typefaces } from "../UiLibrary";
import MeasureWidth from "../MeasureWidth";
+import { useCreateHatchDefs } from "./useCreateHatchDefs";
const margin = { top: 0, left: 0, right: 0, bottom: 40 };
@@ -66,6 +67,7 @@ const BUBBLE_VALUE_Y_OFFSET = 8;
const LegendWrapper = styled.div`
bottom: 0;
+ right: 0;
position: absolute;
`;
@@ -79,6 +81,9 @@ export default function BubbleChart({
height,
}: BubbleChartProps): React.ReactElement {
const { highlighted, setHighlighted } = useHighlightedItem();
+ const { getHatchDefs, generateHatchFill } = useCreateHatchDefs();
+
+ const hatchDefs = getHatchDefs(data);
return (
@@ -121,15 +126,23 @@ export default function BubbleChart({
)
}
nodeSizeAccessor={getRadius}
- nodeStyle={(d) => ({
- fill:
- highlighted && highlighted.label !== d.label
- ? highlightFade(d.color)
- : d.color,
- })}
+ nodeStyle={(d) => {
+ if (isSmallData(data)) {
+ return {
+ fill: generateHatchFill(d.label, highlighted?.label),
+ };
+ }
+ return {
+ fill:
+ highlighted && highlighted.label !== d.label
+ ? highlightFade(d.color)
+ : d.color,
+ };
+ }}
nodes={data}
renderKey="label"
size={[width, height]}
+ additionalDefs={hatchDefs}
/>
diff --git a/spotlight-client/src/charts/ProportionalBar.test.tsx b/spotlight-client/src/charts/ProportionalBar.test.tsx
index 014b4afd..3e2149d0 100644
--- a/spotlight-client/src/charts/ProportionalBar.test.tsx
+++ b/spotlight-client/src/charts/ProportionalBar.test.tsx
@@ -23,8 +23,8 @@ import ProportionalBar from "./ProportionalBar";
jest.mock("../MeasureWidth/MeasureWidth");
const testData = [
- { label: "thing 1", color: "red", value: 10, pct: 0.1639344262 },
- { label: "thing 2", color: "blue", value: 51, pct: 0.8360655738 },
+ { label: "thing 1", color: "red", value: 1000, pct: 0.1639344262 },
+ { label: "thing 2", color: "blue", value: 510, pct: 0.8360655738 },
];
test("renders data", () => {
diff --git a/spotlight-client/src/charts/ProportionalBar.tsx b/spotlight-client/src/charts/ProportionalBar.tsx
index bec33a17..8c751887 100644
--- a/spotlight-client/src/charts/ProportionalBar.tsx
+++ b/spotlight-client/src/charts/ProportionalBar.tsx
@@ -25,7 +25,8 @@ import { animation, colors, zIndex } from "../UiLibrary";
import ColorLegend from "./ColorLegend";
import ResponsiveTooltipController from "./ResponsiveTooltipController";
import { CategoricalChartRecord, ItemToHighlight } from "./types";
-import { useHighlightedItem, highlightFade } from "./utils";
+import { useHighlightedItem, highlightFade, isSmallData } from "./utils";
+import { useCreateHatchDefs } from "./useCreateHatchDefs";
const ProportionalBarContainer = styled.figure`
width: 100%;
@@ -90,11 +91,14 @@ export default function ProportionalBar({
highlighted: localHighlighted,
setHighlighted: setLocalHighlighted,
} = useHighlightedItem();
+ const { getHatchDefs, generateHatchFill } = useCreateHatchDefs();
const noData = data.length === 0 || sum(data.map(({ value }) => value)) === 0;
const highlighted = localHighlighted || externalHighlighted;
+ const hatchDefs = getHatchDefs(data);
+
return (
{({ measureRef, width }) => (
@@ -125,13 +129,21 @@ export default function ProportionalBar({
rAccessor="value"
renderKey="label"
size={[width, height]}
- style={(d: ValuesType) => ({
- fill:
- highlighted && highlighted.label !== d.label
- ? highlightFade(d.color)
- : d.color,
- })}
+ style={(d: ValuesType) => {
+ if (isSmallData(data)) {
+ return {
+ fill: generateHatchFill(d.label, highlighted?.label),
+ };
+ }
+ return {
+ fill:
+ highlighted && highlighted.label !== d.label
+ ? highlightFade(d.color)
+ : d.color,
+ };
+ }}
type="bar"
+ additionalDefs={hatchDefs}
/>
diff --git a/spotlight-client/src/charts/RateTrend.tsx b/spotlight-client/src/charts/RateTrend.tsx
index 47b36113..59d7c673 100644
--- a/spotlight-client/src/charts/RateTrend.tsx
+++ b/spotlight-client/src/charts/RateTrend.tsx
@@ -29,6 +29,7 @@ import MeasureWidth from "../MeasureWidth";
import { DataSeries, ItemToHighlight, TooltipContentFunction } from "./types";
import { RateFields } from "../metricsApi";
import { TooltipContentProps } from "../Tooltip";
+import { STATISTIC_THRESHOLD } from "../constants";
type ChartData = DataSeries[];
@@ -215,6 +216,11 @@ export default function RateTrend({
highlightFade(d?.color, { useOpacity: true })
: d?.color,
strokeWidth: 2,
+ strokeDasharray:
+ d?.coordinates[0].rateDenominator <
+ STATISTIC_THRESHOLD
+ ? "5,7"
+ : 0,
};
}}
lines={data}
diff --git a/spotlight-client/src/charts/useCreateHatchDefs.tsx b/spotlight-client/src/charts/useCreateHatchDefs.tsx
new file mode 100644
index 00000000..cc80cf72
--- /dev/null
+++ b/spotlight-client/src/charts/useCreateHatchDefs.tsx
@@ -0,0 +1,60 @@
+// Recidiviz - a data platform for criminal justice reform
+// Copyright (C) 2022 Recidiviz, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+// =============================================================================
+
+import React, { useCallback } from "react";
+import { PatternLines } from "@vx/pattern";
+import { CommonDataPoint } from "./types";
+import { highlightFade } from "./utils";
+
+export function useCreateHatchDefs(): {
+ getHatchDefs: (data: CommonDataPoint[], hlabel?: string) => React.ReactNode;
+ generateHatchFill: (label: string, highlightedLabel?: string) => string;
+} {
+ const getHatchDefs = useCallback((data) => {
+ return data.flatMap((d: CommonDataPoint) => [
+ ,
+ ,
+ ]);
+ }, []);
+
+ const generateHatchFill = useCallback((label, highlightedLabel) => {
+ const id =
+ highlightedLabel && highlightedLabel !== label
+ ? `${label.replace(/[^\w\d]/g, "")}_highlighted`
+ : label.replace(/[^\w\d]/g, "");
+
+ return `url(#${id})`;
+ }, []);
+
+ return { getHatchDefs, generateHatchFill };
+}
diff --git a/spotlight-client/src/charts/utils.ts b/spotlight-client/src/charts/utils.ts
index 0e86a0c2..562630a5 100644
--- a/spotlight-client/src/charts/utils.ts
+++ b/spotlight-client/src/charts/utils.ts
@@ -17,12 +17,19 @@
import { color } from "d3-color";
import { interpolateRgb } from "d3-interpolate";
+import sumBy from "lodash/sumBy";
import { useCallback, useState } from "react";
+import { STATISTIC_THRESHOLD } from "../constants";
import { colors } from "../UiLibrary";
-import { isItemToHighlight, ItemToHighlight } from "./types";
+import { CommonDataPoint, isItemToHighlight, ItemToHighlight } from "./types";
const FADE_AMOUNT = 0.45;
+export function isSmallData(data: CommonDataPoint[]): boolean {
+ const totalNumber = sumBy(data, ({ value }) => value);
+ return totalNumber < STATISTIC_THRESHOLD;
+}
+
export function highlightFade(
baseColor: string,
{ useOpacity = false } = {}
diff --git a/spotlight-client/src/constants.ts b/spotlight-client/src/constants.ts
index 967606d0..a869185d 100644
--- a/spotlight-client/src/constants.ts
+++ b/spotlight-client/src/constants.ts
@@ -28,6 +28,8 @@ export const NAV_BAR_HEIGHT = 80;
export const FOOTER_HEIGHT = 248;
+export const STATISTIC_THRESHOLD = 100;
+
export const REVOCATION_TYPE_LABELS = {
ABSCOND: "Absconsion",
NEW_CRIME: "New offense",
diff --git a/spotlight-client/src/contentApi/sources/us_nd.ts b/spotlight-client/src/contentApi/sources/us_nd.ts
index 3d58ca40..83b5d389 100644
--- a/spotlight-client/src/contentApi/sources/us_nd.ts
+++ b/spotlight-client/src/contentApi/sources/us_nd.ts
@@ -97,10 +97,9 @@ const content: TenantContent = {
"OTHER",
],
},
- smallDataDisclaimer: `Please always take note of the number of people associated with each
- proportion presented here; in cases where the counts are especially
- low, the proportion may not be statistically significant and therefore
- not indicative of long-term trends.`,
+ smallDataDisclaimer: `Please always take note of the number of people associated with each proportion presented here;
+ in cases where the counts are especially low, the proportion may not be statistically significant and therefore not indicative of long-term trends.
+ Visualizations with fewer than 100 people will appear hashed, dashed, or with another alternative visual treatment.`,
metrics: {
SentencePopulationCurrent: {
name: "Sentenced Population",
diff --git a/spotlight-client/yarn.lock b/spotlight-client/yarn.lock
index 3868ddbe..e6bd94c7 100644
--- a/spotlight-client/yarn.lock
+++ b/spotlight-client/yarn.lock
@@ -1919,6 +1919,13 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf"
integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==
+"@types/classnames@^2.2.9":
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.3.1.tgz#3c2467aa0f1a93f1f021e3b9bcf938bd5dfdc0dd"
+ integrity sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A==
+ dependencies:
+ classnames "*"
+
"@types/d3-array@^2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-2.9.0.tgz#fb6c3d7d7640259e68771cd90cc5db5ac1a1a012"
@@ -2393,6 +2400,16 @@
"@typescript-eslint/types" "4.21.0"
eslint-visitor-keys "^2.0.0"
+"@vx/pattern@^0.0.199":
+ version "0.0.199"
+ resolved "https://registry.yarnpkg.com/@vx/pattern/-/pattern-0.0.199.tgz#7fdab172a64d3567cce1507fc58244fc47206128"
+ integrity sha512-BvEb3Gzika70gtB622a3t/ApNS8EBVL3kdhNOXzXHKZOTphYZnogbs5B3ImEAmPi22woUIOq0w88Xw2YTxd9rA==
+ dependencies:
+ "@types/classnames" "^2.2.9"
+ "@types/react" "*"
+ classnames "^2.2.5"
+ prop-types "^15.5.10"
+
"@w11r/use-breakpoint@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@w11r/use-breakpoint/-/use-breakpoint-1.8.0.tgz#55a9d7fd4ed6b55ca1e694dbefadc6e75c52915c"
@@ -3800,7 +3817,7 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
-classnames@^2.2.5:
+classnames@*, classnames@^2.2.5:
version "2.3.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==