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) => ( - - + ); + })} )} 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==