From eeba611957f7966b45592bfd6627f4d70bbc9bc9 Mon Sep 17 00:00:00 2001 From: nasaownsky Date: Mon, 11 Apr 2022 20:44:03 +0300 Subject: [PATCH 1/4] Added visual treatment for small numbers --- spotlight-client/package.json | 1 + spotlight-client/src/Notes/Notes.tsx | 2 +- .../src/charts/BarChartTrellis.tsx | 26 +++++--- spotlight-client/src/charts/BubbleChart.tsx | 26 +++++--- .../src/charts/ProportionalBar.tsx | 26 +++++--- .../src/charts/useCreateHatchDefs.tsx | 60 +++++++++++++++++++ spotlight-client/src/charts/utils.ts | 5 ++ spotlight-client/src/constants.ts | 2 + spotlight-client/yarn.lock | 19 +++++- 9 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 spotlight-client/src/charts/useCreateHatchDefs.tsx 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..07eb5b03 100644 --- a/spotlight-client/src/Notes/Notes.tsx +++ b/spotlight-client/src/Notes/Notes.tsx @@ -25,7 +25,7 @@ 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; `; diff --git a/spotlight-client/src/charts/BarChartTrellis.tsx b/spotlight-client/src/charts/BarChartTrellis.tsx index 51fa3bf0..0f53973e 100644 --- a/spotlight-client/src/charts/BarChartTrellis.tsx +++ b/spotlight-client/src/charts/BarChartTrellis.tsx @@ -23,10 +23,12 @@ import ResponsiveTooltipController, { ResponsiveTooltipControllerProps, } from "./ResponsiveTooltipController"; import { formatAsPct } from "../utils"; -import { highlightFade } from "./utils"; +import { generateHatchFill, highlightFade } from "./utils"; import { animation } from "../UiLibrary"; import { CategoricalChartRecord, CommonDataPoint } from "./types"; import MeasureWidth from "../MeasureWidth"; +import { useCreateHatchDefs } from "./useCreateHatchDefs"; +import { STATISTIC_THRESHOLD } from "../constants"; export const singleChartHeight = 300; @@ -78,6 +80,8 @@ export function BarChartTrellis({ [setHighlightedLabel] ); + const hatchDefs = useCreateHatchDefs(data[0].records, highlightedLabel); + return ( {({ measureRef, width }) => ( @@ -139,15 +143,23 @@ export function BarChartTrellis({ rAccessor="pct" rExtent={[0, 1]} size={[width, singleChartHeight]} - style={(d: CommonDataPoint) => ({ - fill: - highlightedLabel && highlightedLabel !== d.label - ? highlightFade(d.color) - : d.color, - })} + style={(d: CommonDataPoint) => { + if (d.value < STATISTIC_THRESHOLD) { + return { + fill: generateHatchFill(d.label), + }; + } + 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.tsx b/spotlight-client/src/charts/BubbleChart.tsx index d0098f6d..44b8fca8 100644 --- a/spotlight-client/src/charts/BubbleChart.tsx +++ b/spotlight-client/src/charts/BubbleChart.tsx @@ -32,10 +32,12 @@ 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, generateHatchFill } from "./utils"; import { CategoricalChartRecord } from "./types"; import { animation, colors, typefaces } from "../UiLibrary"; import MeasureWidth from "../MeasureWidth"; +import { useCreateHatchDefs } from "./useCreateHatchDefs"; +import { STATISTIC_THRESHOLD } from "../constants"; const margin = { top: 0, left: 0, right: 0, bottom: 40 }; @@ -66,6 +68,7 @@ const BUBBLE_VALUE_Y_OFFSET = 8; const LegendWrapper = styled.div` bottom: 0; + right: 0; position: absolute; `; @@ -79,6 +82,7 @@ export default function BubbleChart({ height, }: BubbleChartProps): React.ReactElement { const { highlighted, setHighlighted } = useHighlightedItem(); + const hatchDefs = useCreateHatchDefs(data, highlighted); return ( @@ -121,15 +125,23 @@ export default function BubbleChart({ ) } nodeSizeAccessor={getRadius} - nodeStyle={(d) => ({ - fill: - highlighted && highlighted.label !== d.label - ? highlightFade(d.color) - : d.color, - })} + nodeStyle={(d) => { + if (d.value < STATISTIC_THRESHOLD) { + return { + fill: generateHatchFill(d.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.tsx b/spotlight-client/src/charts/ProportionalBar.tsx index bec33a17..533b7c99 100644 --- a/spotlight-client/src/charts/ProportionalBar.tsx +++ b/spotlight-client/src/charts/ProportionalBar.tsx @@ -25,7 +25,9 @@ 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, generateHatchFill } from "./utils"; +import { STATISTIC_THRESHOLD } from "../constants"; +import { useCreateHatchDefs } from "./useCreateHatchDefs"; const ProportionalBarContainer = styled.figure` width: 100%; @@ -95,6 +97,8 @@ export default function ProportionalBar({ const highlighted = localHighlighted || externalHighlighted; + const hatchDefs = useCreateHatchDefs(data, highlighted); + 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 (d.value < STATISTIC_THRESHOLD) { + return { + fill: generateHatchFill(d.label), + }; + } + return { + fill: + highlighted && highlighted.label !== d.label + ? highlightFade(d.color) + : d.color, + }; + }} type="bar" + additionalDefs={hatchDefs} /> diff --git a/spotlight-client/src/charts/useCreateHatchDefs.tsx b/spotlight-client/src/charts/useCreateHatchDefs.tsx new file mode 100644 index 00000000..9a525877 --- /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, { useState, useEffect } from "react"; +import { PatternLines } from "@vx/pattern"; +import { CategoricalChartRecord, ItemToHighlight } from "./types"; +import { highlightFade } from "./utils"; +import { STATISTIC_THRESHOLD } from "../constants"; + +export function useCreateHatchDefs( + data: CategoricalChartRecord[], + highlighted: ItemToHighlight | undefined, + type?: "rate" +): React.ReactNode[] { + const [hatchDefs, setHatchDefs] = useState([]); + + useEffect(() => { + const defs = data.reduce( + (acc: React.ReactNode[], d: CategoricalChartRecord) => { + if (d.value < STATISTIC_THRESHOLD) { + acc.push( + + ); + } + return acc; + }, + [] + ); + + setHatchDefs(defs); + }, [data, highlighted, type]); + + return hatchDefs; +} diff --git a/spotlight-client/src/charts/utils.ts b/spotlight-client/src/charts/utils.ts index 0e86a0c2..ed7d7540 100644 --- a/spotlight-client/src/charts/utils.ts +++ b/spotlight-client/src/charts/utils.ts @@ -23,6 +23,11 @@ import { isItemToHighlight, ItemToHighlight } from "./types"; const FADE_AMOUNT = 0.45; +export function generateHatchFill(id: string): string { + const cleanId = id.replace(/[^\w\d]/g, ""); + return `url(#${cleanId})`; +} + 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/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== From eb294e81dce4a1c65bc2b5732dedf1a7c6a8e83c Mon Sep 17 00:00:00 2001 From: nasaownsky Date: Mon, 11 Apr 2022 20:44:14 +0300 Subject: [PATCH 2/4] Test fixes --- .../VizPopulationBreakdownByLocation.test.tsx | 43 ++++++------------- .../VizPrisonStayLengths.test.tsx | 16 ++++--- .../src/charts/BarChartTrellis.test.tsx | 7 +-- .../src/charts/BubbleChart.test.tsx | 20 ++++++--- .../src/charts/ProportionalBar.test.tsx | 4 +- 5 files changed, 44 insertions(+), 46 deletions(-) diff --git a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx index 04ef012a..cc5c9dc7 100644 --- a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx +++ b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx @@ -76,7 +76,7 @@ test("total counts", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 96", }) - ).toHaveStyle(`fill: ${colors.dataViz[1]}`); + ).toHaveStyle(`fill: url(#Black)`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 123", @@ -91,7 +91,7 @@ test("total counts", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 12", }) - ).toHaveStyle(`fill: ${colors.dataViz[4]}`); + ).toHaveStyle(`fill: url(#Other)`); const ageChart = screen.getByRole("figure", { name: "Age Group" }); expect( @@ -116,12 +116,12 @@ test("total counts", async () => { within(ageChart).getByRole("img", { name: "Age Group bar value 45", }) - ).toHaveStyle(`fill: ${colors.dataViz[3]}`); + ).toHaveStyle(`fill: url(#4049)`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 66", }) - ).toHaveStyle(`fill: ${colors.dataViz[4]}`); + ).toHaveStyle(`fill: url(#5059)`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 104", @@ -131,7 +131,7 @@ test("total counts", async () => { within(ageChart).getByRole("img", { name: "Age Group bar value 51", }) - ).toHaveStyle(`fill: ${colors.dataViz[6]}`); + ).toHaveStyle(`fill: url(#70)`); const genderChart = screen.getByRole("figure", { name: "Gender" }); expect( @@ -146,7 +146,7 @@ test("total counts", async () => { within(genderChart).getByRole("img", { name: "Gender bar value 67", }) - ).toHaveStyle(`fill: ${colors.dataViz[1]}`); + ).toHaveStyle(`fill: url(#Female)`); }); test("counts filtered by locality", async () => { @@ -178,17 +178,17 @@ test("counts filtered by locality", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 99", }) - ).toHaveStyle(`fill: ${colors.dataViz[0]}`); + ).toHaveStyle(`fill: url(#NativeAmerican)`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 124", }) - ).toHaveStyle(`fill: ${colors.dataViz[1]}`); + ).toHaveStyle(`fill: rgb(217, 169, 95)`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 71", }) - ).toHaveStyle(`fill: ${colors.dataViz[2]}`); + ).toHaveStyle(`fill: url(#Hispanic)`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 299", @@ -198,22 +198,12 @@ test("counts filtered by locality", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 10", }) - ).toHaveStyle(`fill: ${colors.dataViz[4]}`); + ).toHaveStyle(`fill: url(#Other)`); const ageChart = screen.getByRole("figure", { name: "Age Group" }); expect( within(ageChart).getByRole("group", { name: "7 bars in a bar chart" }) ).toBeVisible(); - expect( - within(ageChart).getByRole("img", { - name: "Age Group bar value 58", - }) - ).toHaveStyle(`fill: ${colors.dataViz[0]}`); - expect( - within(ageChart).getByRole("img", { - name: "Age Group bar value 82", - }) - ).toHaveStyle(`fill: ${colors.dataViz[1]}`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 249", @@ -223,22 +213,17 @@ test("counts filtered by locality", async () => { within(ageChart).getByRole("img", { name: "Age Group bar value 172", }) - ).toHaveStyle(`fill: ${colors.dataViz[3]}`); + ).toHaveStyle(`fill: rgb(76, 98, 144)`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 15", }) - ).toHaveStyle(`fill: ${colors.dataViz[4]}`); - expect( - within(ageChart).getByRole("img", { - name: "Age Group bar value 26", - }) - ).toHaveStyle(`fill: ${colors.dataViz[5]}`); + ).toHaveStyle(`fill: url(#5059)`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 23", }) - ).toHaveStyle(`fill: ${colors.dataViz[6]}`); + ).toHaveStyle(`fill: url(#70)`); const genderChart = screen.getByRole("figure", { name: "Gender" }); expect( @@ -253,5 +238,5 @@ test("counts filtered by locality", async () => { within(genderChart).getByRole("img", { name: "Gender bar value 1", }) - ).toHaveStyle(`fill: ${colors.dataViz[1]}`); + ).toHaveStyle(`fill: url(#Female)`); }); diff --git a/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx index 659ceff1..af8b6e00 100644 --- a/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx +++ b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx @@ -136,18 +136,22 @@ test("demographic charts", async () => { ).toBe(7); }); -test("all bars are the same color", async () => { +test("bars has its own styling", async () => { renderWithStore(); await when(() => !metric.isLoading); const chart = screen.getByRole("group", { name: "7 bars in a bar chart" }); - within(chart) - .getAllByRole("img") - .forEach((el) => - expect(el).toHaveStyle(`fill: ${colors.dataVizNamed.teal}`) - ); + const hatchedBar = within(chart).getByRole("img", { + name: "1–2 bar value 1%", + }); + expect(hatchedBar).toHaveStyle(`fill: url(#12)`); + + const solidBar = within(chart).getByRole("img", { + name: "2–3 bar value 17%", + }); + expect(solidBar).toHaveStyle(`fill: ${colors.dataVizNamed.teal}`); expect.hasAssertions(); }); diff --git a/spotlight-client/src/charts/BarChartTrellis.test.tsx b/spotlight-client/src/charts/BarChartTrellis.test.tsx index df45c904..a173d151 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,7 +54,8 @@ test("renders charts", () => { ).toHaveStyle("fill: red"); expect( screen.getByRole("img", { name: "Category A bar value 40%" }) - ).toHaveStyle("fill: red"); + // if value is less than certain threshold (e.g. n=100), then the bar is rendered as a hatch + ).toHaveStyle("fill: url(#CategoryA)"); expect( screen.getByRole("img", { name: "Category B bar value 70%" }) ).toHaveStyle("fill: blue"); diff --git a/spotlight-client/src/charts/BubbleChart.test.tsx b/spotlight-client/src/charts/BubbleChart.test.tsx index d2d9037a..7bb65832 100644 --- a/spotlight-client/src/charts/BubbleChart.test.tsx +++ b/spotlight-client/src/charts/BubbleChart.test.tsx @@ -17,15 +17,17 @@ import { screen, within } from "@testing-library/react"; import React from "react"; +import { STATISTIC_THRESHOLD } from "../constants"; import { renderWithStore } from "../testUtils"; import BubbleChart from "./BubbleChart"; +import { generateHatchFill } from "./utils"; 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", () => { @@ -35,11 +37,17 @@ test("renders bubbles for data", () => { const bubbles = within(chart).getByRole("group", { name: "nodes" }); expect(bubbles).toBeVisible(); testData.forEach((record) => { - expect( - // these are the only Semiotic labels we have to work with here - within(bubbles).getByRole("img", { name: `Node ${record.label}` }) - ).toHaveStyle(`fill: ${record.color}`); - // unfortunately there isn't really any sensible way to inspect the bubble size within JSDOM + if (record.value < STATISTIC_THRESHOLD) { + expect( + // these are the only Semiotic labels we have to work with here + within(bubbles).getByRole("img", { name: `Node ${record.label}` }) + ).toHaveStyle(`fill: ${generateHatchFill(record.label)}`); + } else { + expect( + within(bubbles).getByRole("img", { name: `Node ${record.label}` }) + ).toHaveStyle(`fill: ${record.color}`); + // unfortunately there isn't really any sensible way to inspect the bubble size within JSDOM + } }); // record values should be labeled as percentages 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", () => { From 8e1c2c3a37598bd694e2b82314e4ad946932906f Mon Sep 17 00:00:00 2001 From: nasaownsky Date: Fri, 15 Apr 2022 17:49:44 +0300 Subject: [PATCH 3/4] Fixes --- spotlight-client/src/Notes/Notes.tsx | 1 - .../src/charts/BarChartTrellis.tsx | 150 +++++++++--------- spotlight-client/src/charts/BubbleChart.tsx | 11 +- .../src/charts/ProportionalBar.tsx | 10 +- spotlight-client/src/charts/RateTrend.tsx | 6 + .../src/charts/useCreateHatchDefs.tsx | 72 ++++----- spotlight-client/src/charts/utils.ts | 10 +- .../src/contentApi/sources/us_nd.ts | 7 +- 8 files changed, 138 insertions(+), 129 deletions(-) diff --git a/spotlight-client/src/Notes/Notes.tsx b/spotlight-client/src/Notes/Notes.tsx index 07eb5b03..3af5bbc5 100644 --- a/spotlight-client/src/Notes/Notes.tsx +++ b/spotlight-client/src/Notes/Notes.tsx @@ -27,7 +27,6 @@ const Wrapper = styled.ol` line-height: 1.7; list-style: none; margin-top: ${rem(40)}; - padding-left: 1em; `; const Item = styled.li` diff --git a/spotlight-client/src/charts/BarChartTrellis.tsx b/spotlight-client/src/charts/BarChartTrellis.tsx index 0f53973e..67a1f579 100644 --- a/spotlight-client/src/charts/BarChartTrellis.tsx +++ b/spotlight-client/src/charts/BarChartTrellis.tsx @@ -23,12 +23,11 @@ import ResponsiveTooltipController, { ResponsiveTooltipControllerProps, } from "./ResponsiveTooltipController"; import { formatAsPct } from "../utils"; -import { generateHatchFill, 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"; -import { STATISTIC_THRESHOLD } from "../constants"; export const singleChartHeight = 300; @@ -73,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( @@ -80,89 +80,91 @@ export function BarChartTrellis({ [setHighlightedLabel] ); - const hatchDefs = useCreateHatchDefs(data[0].records, highlightedLabel); - return ( {({ measureRef, width }) => ( {width === 0 ? null - : data.map(({ label, records: chartData }, index) => ( - - + ); + })} )} diff --git a/spotlight-client/src/charts/BubbleChart.tsx b/spotlight-client/src/charts/BubbleChart.tsx index 44b8fca8..dd997f90 100644 --- a/spotlight-client/src/charts/BubbleChart.tsx +++ b/spotlight-client/src/charts/BubbleChart.tsx @@ -32,12 +32,11 @@ import styled from "styled-components/macro"; import ColorLegend from "./ColorLegend"; import ResponsiveTooltipController from "./ResponsiveTooltipController"; import { formatAsPct } from "../utils"; -import { useHighlightedItem, highlightFade, generateHatchFill } 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"; -import { STATISTIC_THRESHOLD } from "../constants"; const margin = { top: 0, left: 0, right: 0, bottom: 40 }; @@ -82,7 +81,9 @@ export default function BubbleChart({ height, }: BubbleChartProps): React.ReactElement { const { highlighted, setHighlighted } = useHighlightedItem(); - const hatchDefs = useCreateHatchDefs(data, highlighted); + const { getHatchDefs, generateHatchFill } = useCreateHatchDefs(); + + const hatchDefs = getHatchDefs(data); return ( @@ -126,9 +127,9 @@ export default function BubbleChart({ } nodeSizeAccessor={getRadius} nodeStyle={(d) => { - if (d.value < STATISTIC_THRESHOLD) { + if (isSmallData(data)) { return { - fill: generateHatchFill(d.label), + fill: generateHatchFill(d.label, highlighted?.label), }; } return { diff --git a/spotlight-client/src/charts/ProportionalBar.tsx b/spotlight-client/src/charts/ProportionalBar.tsx index 533b7c99..8c751887 100644 --- a/spotlight-client/src/charts/ProportionalBar.tsx +++ b/spotlight-client/src/charts/ProportionalBar.tsx @@ -25,8 +25,7 @@ import { animation, colors, zIndex } from "../UiLibrary"; import ColorLegend from "./ColorLegend"; import ResponsiveTooltipController from "./ResponsiveTooltipController"; import { CategoricalChartRecord, ItemToHighlight } from "./types"; -import { useHighlightedItem, highlightFade, generateHatchFill } from "./utils"; -import { STATISTIC_THRESHOLD } from "../constants"; +import { useHighlightedItem, highlightFade, isSmallData } from "./utils"; import { useCreateHatchDefs } from "./useCreateHatchDefs"; const ProportionalBarContainer = styled.figure` @@ -92,12 +91,13 @@ 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 = useCreateHatchDefs(data, highlighted); + const hatchDefs = getHatchDefs(data); return ( @@ -130,9 +130,9 @@ export default function ProportionalBar({ renderKey="label" size={[width, height]} style={(d: ValuesType) => { - if (d.value < STATISTIC_THRESHOLD) { + if (isSmallData(data)) { return { - fill: generateHatchFill(d.label), + fill: generateHatchFill(d.label, highlighted?.label), }; } return { 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 index 9a525877..cc80cf72 100644 --- a/spotlight-client/src/charts/useCreateHatchDefs.tsx +++ b/spotlight-client/src/charts/useCreateHatchDefs.tsx @@ -15,46 +15,46 @@ // along with this program. If not, see . // ============================================================================= -import React, { useState, useEffect } from "react"; +import React, { useCallback } from "react"; import { PatternLines } from "@vx/pattern"; -import { CategoricalChartRecord, ItemToHighlight } from "./types"; +import { CommonDataPoint } from "./types"; import { highlightFade } from "./utils"; -import { STATISTIC_THRESHOLD } from "../constants"; -export function useCreateHatchDefs( - data: CategoricalChartRecord[], - highlighted: ItemToHighlight | undefined, - type?: "rate" -): React.ReactNode[] { - const [hatchDefs, setHatchDefs] = useState([]); +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) => [ + , + , + ]); + }, []); - useEffect(() => { - const defs = data.reduce( - (acc: React.ReactNode[], d: CategoricalChartRecord) => { - if (d.value < STATISTIC_THRESHOLD) { - acc.push( - - ); - } - return acc; - }, - [] - ); + const generateHatchFill = useCallback((label, highlightedLabel) => { + const id = + highlightedLabel && highlightedLabel !== label + ? `${label.replace(/[^\w\d]/g, "")}_highlighted` + : label.replace(/[^\w\d]/g, ""); - setHatchDefs(defs); - }, [data, highlighted, type]); + return `url(#${id})`; + }, []); - return hatchDefs; + return { getHatchDefs, generateHatchFill }; } diff --git a/spotlight-client/src/charts/utils.ts b/spotlight-client/src/charts/utils.ts index ed7d7540..562630a5 100644 --- a/spotlight-client/src/charts/utils.ts +++ b/spotlight-client/src/charts/utils.ts @@ -17,15 +17,17 @@ 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 generateHatchFill(id: string): string { - const cleanId = id.replace(/[^\w\d]/g, ""); - return `url(#${cleanId})`; +export function isSmallData(data: CommonDataPoint[]): boolean { + const totalNumber = sumBy(data, ({ value }) => value); + return totalNumber < STATISTIC_THRESHOLD; } export function highlightFade( 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", From 54d9525eabfa7a08f44135f9439194cda0ebc191 Mon Sep 17 00:00:00 2001 From: nasaownsky Date: Fri, 15 Apr 2022 17:49:53 +0300 Subject: [PATCH 4/4] Test fixes --- .../VizPopulationBreakdownByLocation.test.tsx | 43 +++++++++++++------ .../VizPrisonStayLengths.test.tsx | 16 +++---- .../src/charts/BarChartTrellis.test.tsx | 2 +- .../src/charts/BubbleChart.test.tsx | 18 +++----- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx index cc5c9dc7..04ef012a 100644 --- a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx +++ b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx @@ -76,7 +76,7 @@ test("total counts", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 96", }) - ).toHaveStyle(`fill: url(#Black)`); + ).toHaveStyle(`fill: ${colors.dataViz[1]}`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 123", @@ -91,7 +91,7 @@ test("total counts", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 12", }) - ).toHaveStyle(`fill: url(#Other)`); + ).toHaveStyle(`fill: ${colors.dataViz[4]}`); const ageChart = screen.getByRole("figure", { name: "Age Group" }); expect( @@ -116,12 +116,12 @@ test("total counts", async () => { within(ageChart).getByRole("img", { name: "Age Group bar value 45", }) - ).toHaveStyle(`fill: url(#4049)`); + ).toHaveStyle(`fill: ${colors.dataViz[3]}`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 66", }) - ).toHaveStyle(`fill: url(#5059)`); + ).toHaveStyle(`fill: ${colors.dataViz[4]}`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 104", @@ -131,7 +131,7 @@ test("total counts", async () => { within(ageChart).getByRole("img", { name: "Age Group bar value 51", }) - ).toHaveStyle(`fill: url(#70)`); + ).toHaveStyle(`fill: ${colors.dataViz[6]}`); const genderChart = screen.getByRole("figure", { name: "Gender" }); expect( @@ -146,7 +146,7 @@ test("total counts", async () => { within(genderChart).getByRole("img", { name: "Gender bar value 67", }) - ).toHaveStyle(`fill: url(#Female)`); + ).toHaveStyle(`fill: ${colors.dataViz[1]}`); }); test("counts filtered by locality", async () => { @@ -178,17 +178,17 @@ test("counts filtered by locality", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 99", }) - ).toHaveStyle(`fill: url(#NativeAmerican)`); + ).toHaveStyle(`fill: ${colors.dataViz[0]}`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 124", }) - ).toHaveStyle(`fill: rgb(217, 169, 95)`); + ).toHaveStyle(`fill: ${colors.dataViz[1]}`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 71", }) - ).toHaveStyle(`fill: url(#Hispanic)`); + ).toHaveStyle(`fill: ${colors.dataViz[2]}`); expect( within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 299", @@ -198,12 +198,22 @@ test("counts filtered by locality", async () => { within(raceChart).getByRole("img", { name: "Race or Ethnicity bar value 10", }) - ).toHaveStyle(`fill: url(#Other)`); + ).toHaveStyle(`fill: ${colors.dataViz[4]}`); const ageChart = screen.getByRole("figure", { name: "Age Group" }); expect( within(ageChart).getByRole("group", { name: "7 bars in a bar chart" }) ).toBeVisible(); + expect( + within(ageChart).getByRole("img", { + name: "Age Group bar value 58", + }) + ).toHaveStyle(`fill: ${colors.dataViz[0]}`); + expect( + within(ageChart).getByRole("img", { + name: "Age Group bar value 82", + }) + ).toHaveStyle(`fill: ${colors.dataViz[1]}`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 249", @@ -213,17 +223,22 @@ test("counts filtered by locality", async () => { within(ageChart).getByRole("img", { name: "Age Group bar value 172", }) - ).toHaveStyle(`fill: rgb(76, 98, 144)`); + ).toHaveStyle(`fill: ${colors.dataViz[3]}`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 15", }) - ).toHaveStyle(`fill: url(#5059)`); + ).toHaveStyle(`fill: ${colors.dataViz[4]}`); + expect( + within(ageChart).getByRole("img", { + name: "Age Group bar value 26", + }) + ).toHaveStyle(`fill: ${colors.dataViz[5]}`); expect( within(ageChart).getByRole("img", { name: "Age Group bar value 23", }) - ).toHaveStyle(`fill: url(#70)`); + ).toHaveStyle(`fill: ${colors.dataViz[6]}`); const genderChart = screen.getByRole("figure", { name: "Gender" }); expect( @@ -238,5 +253,5 @@ test("counts filtered by locality", async () => { within(genderChart).getByRole("img", { name: "Gender bar value 1", }) - ).toHaveStyle(`fill: url(#Female)`); + ).toHaveStyle(`fill: ${colors.dataViz[1]}`); }); diff --git a/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx index af8b6e00..659ceff1 100644 --- a/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx +++ b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx @@ -136,22 +136,18 @@ test("demographic charts", async () => { ).toBe(7); }); -test("bars has its own styling", async () => { +test("all bars are the same color", async () => { renderWithStore(); await when(() => !metric.isLoading); const chart = screen.getByRole("group", { name: "7 bars in a bar chart" }); - const hatchedBar = within(chart).getByRole("img", { - name: "1–2 bar value 1%", - }); - expect(hatchedBar).toHaveStyle(`fill: url(#12)`); - - const solidBar = within(chart).getByRole("img", { - name: "2–3 bar value 17%", - }); - expect(solidBar).toHaveStyle(`fill: ${colors.dataVizNamed.teal}`); + within(chart) + .getAllByRole("img") + .forEach((el) => + expect(el).toHaveStyle(`fill: ${colors.dataVizNamed.teal}`) + ); expect.hasAssertions(); }); diff --git a/spotlight-client/src/charts/BarChartTrellis.test.tsx b/spotlight-client/src/charts/BarChartTrellis.test.tsx index a173d151..a0069bc0 100644 --- a/spotlight-client/src/charts/BarChartTrellis.test.tsx +++ b/spotlight-client/src/charts/BarChartTrellis.test.tsx @@ -55,7 +55,7 @@ test("renders charts", () => { 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: url(#CategoryA)"); + ).toHaveStyle("fill: red"); expect( screen.getByRole("img", { name: "Category B bar value 70%" }) ).toHaveStyle("fill: blue"); diff --git a/spotlight-client/src/charts/BubbleChart.test.tsx b/spotlight-client/src/charts/BubbleChart.test.tsx index 7bb65832..a3733dfe 100644 --- a/spotlight-client/src/charts/BubbleChart.test.tsx +++ b/spotlight-client/src/charts/BubbleChart.test.tsx @@ -17,10 +17,8 @@ import { screen, within } from "@testing-library/react"; import React from "react"; -import { STATISTIC_THRESHOLD } from "../constants"; import { renderWithStore } from "../testUtils"; import BubbleChart from "./BubbleChart"; -import { generateHatchFill } from "./utils"; jest.mock("../MeasureWidth/MeasureWidth"); @@ -37,17 +35,11 @@ test("renders bubbles for data", () => { const bubbles = within(chart).getByRole("group", { name: "nodes" }); expect(bubbles).toBeVisible(); testData.forEach((record) => { - if (record.value < STATISTIC_THRESHOLD) { - expect( - // these are the only Semiotic labels we have to work with here - within(bubbles).getByRole("img", { name: `Node ${record.label}` }) - ).toHaveStyle(`fill: ${generateHatchFill(record.label)}`); - } else { - expect( - within(bubbles).getByRole("img", { name: `Node ${record.label}` }) - ).toHaveStyle(`fill: ${record.color}`); - // unfortunately there isn't really any sensible way to inspect the bubble size within JSDOM - } + expect( + // these are the only Semiotic labels we have to work with here + within(bubbles).getByRole("img", { name: `Node ${record.label}` }) + ).toHaveStyle(`fill: ${record.color}`); + // unfortunately there isn't really any sensible way to inspect the bubble size within JSDOM }); // record values should be labeled as percentages