From ff3fa3e20f98b259de06d0032ae9f8b81285d2c2 Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Wed, 3 Feb 2021 09:21:49 -0800 Subject: [PATCH] Categorical breakdown sections (#332) --- spotlight-client/@types/d3-force-limit.d.ts | 18 + spotlight-client/package.json | 3 + .../DemographicFilterSelect.tsx | 5 +- .../src/MeasureWidth/MeasureWidth.tsx | 2 +- .../MeasureWidth/__mocks__/MeasureWidth.tsx | 27 ++ .../src/MetricVizMapper/MetricVizMapper.tsx | 8 + .../src/NoMetricData/NoMetricData.tsx | 33 ++ spotlight-client/src/NoMetricData/index.ts | 18 + .../VizDemographicsByCategory.test.tsx | 122 ++++++ .../VizDemographicsByCategory.tsx | 146 +++++++ .../src/VizDemographicsByCategory/index.ts | 18 + .../VizHistoricalPopulationBreakdown.tsx | 6 +- .../VizPopulationBreakdownByLocation.test.tsx | 2 + .../VizPopulationBreakdownByLocation.tsx | 6 +- .../src/charts/BubbleChart.test.tsx | 65 +++ spotlight-client/src/charts/BubbleChart.tsx | 250 +++++++++++ .../src/charts/ProportionalBar.test.tsx | 2 + .../src/charts/ProportionalBar.tsx | 104 ++--- spotlight-client/src/charts/types.ts | 6 + .../DemographicsByCategoryMetric.test.ts | 71 +++ .../DemographicsByCategoryMetric.ts | 46 +- ...istoricalPopulationBreakdownMetric.test.ts | 1 + spotlight-client/src/contentModels/Metric.ts | 7 +- .../PopulationBreakdownByLocationMetric.ts | 11 +- .../DemographicsByCategoryMetric.test.ts.snap | 411 ++++++++++++++++++ .../__snapshots__/Metric.test.ts.snap | 130 +++--- ...tionBreakdownByLocationMetric.test.ts.snap | 6 +- .../src/contentModels/createMetricMapping.ts | 21 + spotlight-client/src/contentModels/types.ts | 9 + .../DemographicsByCategoryRecord.ts | 32 +- yarn.lock | 7 +- 31 files changed, 1436 insertions(+), 157 deletions(-) create mode 100644 spotlight-client/@types/d3-force-limit.d.ts create mode 100644 spotlight-client/src/MeasureWidth/__mocks__/MeasureWidth.tsx create mode 100644 spotlight-client/src/NoMetricData/NoMetricData.tsx create mode 100644 spotlight-client/src/NoMetricData/index.ts create mode 100644 spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.test.tsx create mode 100644 spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx create mode 100644 spotlight-client/src/VizDemographicsByCategory/index.ts create mode 100644 spotlight-client/src/charts/BubbleChart.test.tsx create mode 100644 spotlight-client/src/charts/BubbleChart.tsx create mode 100644 spotlight-client/src/contentModels/DemographicsByCategoryMetric.test.ts create mode 100644 spotlight-client/src/contentModels/__snapshots__/DemographicsByCategoryMetric.test.ts.snap diff --git a/spotlight-client/@types/d3-force-limit.d.ts b/spotlight-client/@types/d3-force-limit.d.ts new file mode 100644 index 00000000..e7f6eb3a --- /dev/null +++ b/spotlight-client/@types/d3-force-limit.d.ts @@ -0,0 +1,18 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 . +// ============================================================================= + +declare module "d3-force-limit"; diff --git a/spotlight-client/package.json b/spotlight-client/package.json index 1ab17f4c..58b0c940 100644 --- a/spotlight-client/package.json +++ b/spotlight-client/package.json @@ -20,6 +20,7 @@ "@types/classnames": "^2.2.11", "@types/d3-array": "^2.8.0", "@types/d3-color": "^2.0.1", + "@types/d3-force": "^2.1.0", "@types/d3-format": "^2.0.0", "@types/d3-interpolate": "^2.0.0", "@types/d3-scale": "^3.2.2", @@ -37,6 +38,8 @@ "classnames": "^2.2.6", "d3-array": "^2.9.1", "d3-color": "^2.0.0", + "d3-force": "^2.1.1", + "d3-force-limit": "^1.1.3", "d3-format": "^2.0.0", "d3-interpolate": "^2.0.1", "d3-scale": "^3.2.3", diff --git a/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx b/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx index ea9ab3f8..fffa856b 100644 --- a/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx +++ b/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx @@ -18,7 +18,8 @@ import { action } from "mobx"; import { observer } from "mobx-react-lite"; import React from "react"; -import HistoricalPopulationBreakdownMetric from "../contentModels/HistoricalPopulationBreakdownMetric"; +import type DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; +import type HistoricalPopulationBreakdownMetric from "../contentModels/HistoricalPopulationBreakdownMetric"; import { DemographicView, DemographicViewList, @@ -30,7 +31,7 @@ import { Dropdown } from "../UiLibrary"; type DemographicFilterOption = { id: DemographicView; label: string }; type DemographicFilterSelectProps = { - metric: HistoricalPopulationBreakdownMetric; + metric: HistoricalPopulationBreakdownMetric | DemographicsByCategoryMetric; }; const DemographicFilterSelect: React.FC = ({ diff --git a/spotlight-client/src/MeasureWidth/MeasureWidth.tsx b/spotlight-client/src/MeasureWidth/MeasureWidth.tsx index f4d15ed1..92c1ab2f 100644 --- a/spotlight-client/src/MeasureWidth/MeasureWidth.tsx +++ b/spotlight-client/src/MeasureWidth/MeasureWidth.tsx @@ -18,7 +18,7 @@ import React from "react"; import Measure from "react-measure"; -type MeasureWidthProps = { +export type MeasureWidthProps = { children: (props: { measureRef: (ref: Element | null) => void; width: number; diff --git a/spotlight-client/src/MeasureWidth/__mocks__/MeasureWidth.tsx b/spotlight-client/src/MeasureWidth/__mocks__/MeasureWidth.tsx new file mode 100644 index 00000000..e5648c8c --- /dev/null +++ b/spotlight-client/src/MeasureWidth/__mocks__/MeasureWidth.tsx @@ -0,0 +1,27 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 from "react"; +import { MeasureWidthProps } from "../MeasureWidth"; + +const MockMeasureWidth = ({ + children, +}: MeasureWidthProps): React.ReactElement => { + return children({ measureRef: () => undefined, width: 600 }); +}; + +export default MockMeasureWidth; diff --git a/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx b/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx index 99029628..f5f0f934 100644 --- a/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx +++ b/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx @@ -22,6 +22,8 @@ import VizHistoricalPopulationBreakdown from "../VizHistoricalPopulationBreakdow import { MetricRecord } from "../contentModels/types"; import PopulationBreakdownByLocationMetric from "../contentModels/PopulationBreakdownByLocationMetric"; import VizPopulationBreakdownByLocation from "../VizPopulationBreakdownByLocation"; +import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; +import VizDemographicsByCategory from "../VizDemographicsByCategory"; type MetricVizMapperProps = { metric: Metric; @@ -34,6 +36,12 @@ const MetricVizMapper: React.FC = ({ metric }) => { if (metric instanceof PopulationBreakdownByLocationMetric) { return ; } + if ( + metric instanceof DemographicsByCategoryMetric && + metric.id !== "PrisonStayLengthAggregate" + ) { + return ; + } return

Placeholder for {metric.name}

; }; diff --git a/spotlight-client/src/NoMetricData/NoMetricData.tsx b/spotlight-client/src/NoMetricData/NoMetricData.tsx new file mode 100644 index 00000000..6278b8ee --- /dev/null +++ b/spotlight-client/src/NoMetricData/NoMetricData.tsx @@ -0,0 +1,33 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 from "react"; +import Metric from "../contentModels/Metric"; +import { MetricRecord } from "../contentModels/types"; +import Loading from "../Loading"; + +type NoMetricDataProps = { + metric: Metric; +}; + +const NoMetricData: React.FC = ({ metric }) => { + if (metric.error) throw metric.error; + + return ; +}; + +export default NoMetricData; diff --git a/spotlight-client/src/NoMetricData/index.ts b/spotlight-client/src/NoMetricData/index.ts new file mode 100644 index 00000000..7117a947 --- /dev/null +++ b/spotlight-client/src/NoMetricData/index.ts @@ -0,0 +1,18 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 . +// ============================================================================= + +export { default } from "./NoMetricData"; diff --git a/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.test.tsx b/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.test.tsx new file mode 100644 index 00000000..dea74168 --- /dev/null +++ b/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.test.tsx @@ -0,0 +1,122 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { runInAction, when } from "mobx"; +import React from "react"; +import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; +import DataStore from "../DataStore"; +import { reactImmediately, renderWithStore } from "../testUtils"; +import VizDemographicsByCategory from "./VizDemographicsByCategory"; + +jest.mock("../MeasureWidth/MeasureWidth"); + +let metric: DemographicsByCategoryMetric; + +beforeEach(() => { + runInAction(() => { + DataStore.tenantStore.currentTenantId = "US_ND"; + }); + reactImmediately(() => { + const metricToTest = DataStore.tenant?.metrics.get( + "ProbationRevocationsAggregate" + ); + // it will be + if (metricToTest instanceof DemographicsByCategoryMetric) { + metric = metricToTest; + } + }); +}); + +afterEach(() => { + runInAction(() => { + DataStore.tenantStore.currentTenantId = undefined; + }); +}); + +test("loading", () => { + renderWithStore(); + expect(screen.getByText(/loading/i)).toBeVisible(); +}); + +test("total chart", async () => { + renderWithStore(); + + await when(() => !metric.isLoading); + + const bubbles = screen.getByRole("group", { name: "nodes" }); + expect(bubbles).toBeVisible(); + expect(within(bubbles).getAllByRole("img", { name: /Node/ }).length).toBe(4); +}); + +test("demographic charts", async () => { + renderWithStore(); + + await when(() => !metric.isLoading); + + const menuButton = screen.getByRole("button", { + name: "View Total", + }); + fireEvent.click(menuButton); + fireEvent.click(screen.getByRole("option", { name: "Race or Ethnicity" })); + + // pause for animated transition + await waitFor(() => { + expect( + screen.getByRole("figure", { name: "Native American" }) + ).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "Black" })).toBeInTheDocument(); + expect( + screen.getByRole("figure", { name: "Hispanic" }) + ).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "White" })).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "Other" })).toBeInTheDocument(); + + expect( + screen.getAllByRole("group", { name: "4 bars in a bar chart" }).length + ).toBe(5); + }); + + fireEvent.click(menuButton); + fireEvent.click(screen.getByRole("option", { name: "Gender" })); + + // pause for animated transition + await waitFor(() => { + expect(screen.getByRole("figure", { name: "Male" })).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "Female" })).toBeInTheDocument(); + + expect( + screen.getAllByRole("group", { name: "4 bars in a bar chart" }).length + ).toBe(2); + }); + + fireEvent.click(menuButton); + fireEvent.click(screen.getByRole("option", { name: "Age Group" })); + + // pause for animated transition + await waitFor(() => { + expect(screen.getByRole("figure", { name: "<25" })).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "25-29" })).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "30-34" })).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "35-39" })).toBeInTheDocument(); + expect(screen.getByRole("figure", { name: "40<" })).toBeInTheDocument(); + + expect( + screen.getAllByRole("group", { name: "4 bars in a bar chart" }).length + ).toBe(5); + }); +}); diff --git a/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx b/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx new file mode 100644 index 00000000..d3e10327 --- /dev/null +++ b/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx @@ -0,0 +1,146 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { observer } from "mobx-react-lite"; +import { rem } from "polished"; +import React, { useState } from "react"; +import Measure from "react-measure"; +import { animated, useSpring, useTransition } from "react-spring/web.cjs"; +import styled from "styled-components/macro"; +import { ItemToHighlight, ProportionalBar } from "../charts"; +import BubbleChart from "../charts/BubbleChart"; +import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; +import DemographicFilterSelect from "../DemographicFilterSelect"; +import FiltersWrapper from "../FiltersWrapper"; +import NoMetricData from "../NoMetricData"; +import { zIndex } from "../UiLibrary"; + +const bubbleChartHeight = 325; + +const barChartsHeight = 460; +const barChartsGutter = 42; + +const ChartWrapper = styled.div` + position: relative; +`; + +const CategoryBarWrapper = styled.div` + padding-bottom: ${rem(16)}; + position: relative; + width: 100%; +`; + +type VizDemographicsByCategoryProps = { + metric: DemographicsByCategoryMetric; +}; + +const VizDemographicsByCategory: React.FC = ({ + metric, +}) => { + const [highlighted, setHighlighted] = useState(); + + const { demographicView, dataSeries } = metric; + + const [chartContainerStyles, setChartContainerStyles] = useSpring(() => ({ + from: { height: bubbleChartHeight }, + height: bubbleChartHeight, + config: { friction: 40, tension: 280, clamp: true }, + })); + + const chartTransitions = useTransition( + { demographicView, dataSeries }, + (item) => item.demographicView, + { + initial: { opacity: 1 }, + from: { opacity: 0 }, + enter: { opacity: 1 }, + leave: { opacity: 0, position: "absolute" }, + config: { friction: 40, tension: 280 }, + } + ); + + if (demographicView === "nofilter") + throw new Error( + "Unable to display this metric without demographic filter." + ); + + if (dataSeries) { + return ( + { + if (bounds) setChartContainerStyles({ height: bounds.height }); + }} + > + {({ measureRef }) => ( + <> + ]} + /> + + {chartTransitions.map(({ item, key, props }) => ( + + + { + // for type safety we have to check this again + // but it should always be defined if we've gotten this far + item.dataSeries && + (item.demographicView === "total" ? ( + + ) : ( + item.dataSeries.map( + ({ label, records }, index, categories) => ( + + + + ) + ) + )) + } + + + ))} + + + )} + + ); + } + + return ; +}; + +export default observer(VizDemographicsByCategory); diff --git a/spotlight-client/src/VizDemographicsByCategory/index.ts b/spotlight-client/src/VizDemographicsByCategory/index.ts new file mode 100644 index 00000000..9cf5ea9f --- /dev/null +++ b/spotlight-client/src/VizDemographicsByCategory/index.ts @@ -0,0 +1,18 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 . +// ============================================================================= + +export { default } from "./VizDemographicsByCategory"; diff --git a/spotlight-client/src/VizHistoricalPopulationBreakdown/VizHistoricalPopulationBreakdown.tsx b/spotlight-client/src/VizHistoricalPopulationBreakdown/VizHistoricalPopulationBreakdown.tsx index 1b223d12..6597f57f 100644 --- a/spotlight-client/src/VizHistoricalPopulationBreakdown/VizHistoricalPopulationBreakdown.tsx +++ b/spotlight-client/src/VizHistoricalPopulationBreakdown/VizHistoricalPopulationBreakdown.tsx @@ -22,7 +22,7 @@ import { isWindowSizeId, WindowedTimeSeries, WindowSizeId } from "../charts"; import type HistoricalPopulationBreakdownMetric from "../contentModels/HistoricalPopulationBreakdownMetric"; import DemographicFilterSelect from "../DemographicFilterSelect"; import FiltersWrapper from "../FiltersWrapper"; -import Loading from "../Loading"; +import NoMetricData from "../NoMetricData"; import { Dropdown } from "../UiLibrary"; const VizHistoricalPopulationBreakdown: React.FC<{ @@ -75,9 +75,7 @@ const VizHistoricalPopulationBreakdown: React.FC<{ ); - if (metric.error) throw metric.error; - - return ; + return ; }; export default observer(VizHistoricalPopulationBreakdown); diff --git a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx index 65453ffe..1603ce0d 100644 --- a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx +++ b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.test.tsx @@ -24,6 +24,8 @@ import DataStore from "../DataStore"; import { reactImmediately, renderWithStore } from "../testUtils"; import { colors } from "../UiLibrary"; +jest.mock("../MeasureWidth/MeasureWidth"); + let metric: PopulationBreakdownByLocationMetric; beforeEach(() => { diff --git a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.tsx b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.tsx index a02e072d..bd25bd86 100644 --- a/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.tsx +++ b/spotlight-client/src/VizPopulationBreakdownByLocation/VizPopulationBreakdownByLocation.tsx @@ -22,8 +22,8 @@ import styled from "styled-components/macro"; import { ProportionalBar } from "../charts"; import PopulationBreakdownByLocationMetric from "../contentModels/PopulationBreakdownByLocationMetric"; import FiltersWrapper from "../FiltersWrapper"; -import Loading from "../Loading"; import LocalityFilterSelect from "../LocalityFilterSelect"; +import NoMetricData from "../NoMetricData"; import Statistic from "../Statistic"; import { formatAsNumber } from "../utils"; @@ -46,7 +46,7 @@ const VizPopulationBreakdownByLocation: React.FC ]} /> - {metric.dataSeries.map(({ viewName, records }) => ( + {metric.dataSeries.map(({ label: viewName, records }) => ( @@ -65,7 +65,7 @@ const VizPopulationBreakdownByLocation: React.FC; + return ; }; export default observer(VizPopulationBreakdownByLocation); diff --git a/spotlight-client/src/charts/BubbleChart.test.tsx b/spotlight-client/src/charts/BubbleChart.test.tsx new file mode 100644 index 00000000..67a86a60 --- /dev/null +++ b/spotlight-client/src/charts/BubbleChart.test.tsx @@ -0,0 +1,65 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import React from "react"; +import { renderWithStore } from "../testUtils"; +import BubbleChart from "./BubbleChart"; + +jest.mock("../MeasureWidth/MeasureWidth"); + +const testData = [ + { label: "thing 1", color: "red", value: 10 }, + { label: "thing 2", color: "blue", value: 50 }, + { label: "thing 3", color: "green", value: 32 }, +]; + +test("renders bubbles for data", () => { + renderWithStore(); + + const chart = screen.getByRole("figure"); + 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 + }); + + // record values should be labeled as percentages + expect(within(chart).getByText("11%")).toBeVisible(); + expect(within(chart).getByText("54%")).toBeVisible(); + expect(within(chart).getByText("35%")).toBeVisible(); +}); + +test("highlight when hovering on legend", async () => { + renderWithStore(); + const firstLegendItem = screen.getByText(testData[0].label); + fireEvent.mouseOver(firstLegendItem); + + // wait for highlight animation + await waitFor(() => { + expect( + screen.getByRole("img", { name: `Node ${testData[1].label}` }) + ).not.toHaveStyle(`fill: ${testData[1].color}`); + expect( + screen.getByRole("img", { name: `Node ${testData[2].label}` }) + ).not.toHaveStyle(`fill: ${testData[2].color}`); + }); +}); diff --git a/spotlight-client/src/charts/BubbleChart.tsx b/spotlight-client/src/charts/BubbleChart.tsx new file mode 100644 index 00000000..3662c7f8 --- /dev/null +++ b/spotlight-client/src/charts/BubbleChart.tsx @@ -0,0 +1,250 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { cumsum } from "d3-array"; +import { + forceCollide, + forceSimulation, + forceX, + forceY, + SimulationNodeDatum, +} from "d3-force"; +import forceLimit from "d3-force-limit"; +import { scaleSqrt } from "d3-scale"; +import { rem } from "polished"; +import React, { useState } from "react"; +import NetworkFrame from "semiotic/lib/NetworkFrame"; +import styled from "styled-components/macro"; +import ColorLegend from "./ColorLegend"; +import ResponsiveTooltipController from "./ResponsiveTooltipController"; +import { formatAsPct } from "../utils"; +import { getDataWithPct, highlightFade } from "./utils"; +import { CategoricalChartRecord, ItemToHighlight } from "./types"; +import { animation, colors, typefaces } from "../UiLibrary"; +import MeasureWidth from "../MeasureWidth"; + +const margin = { top: 0, left: 0, right: 0, bottom: 40 }; + +const BubbleChartWrapper = styled.figure` + position: relative; + .visualization-layer { + /* + circles that are very close to the edge might + overlap by a couple of pixels, that's fine. don't clip them + */ + overflow: visible; + } +`; + +const BubbleValueLabel = styled.text` + fill: ${colors.textLight}; + font-family: ${typefaces.display}; + font-size: ${rem(32)}; + letter-spacing: -0.02em; + line-height: 1; + font-size: 20px; + text-anchor: middle; +`; +// this is a magic number based on the font size of BubbleValueLabel; +// it is a workaround for the lack of IE support for the dominant-baseline attribute +// to vertically center this text relative to its origin +const BUBBLE_VALUE_Y_OFFSET = 8; + +const LegendWrapper = styled.div` + bottom: 0; + position: absolute; +`; + +type BubbleChartProps = { + data: CategoricalChartRecord[]; + height: number; +}; + +export default function BubbleChart({ + data: initialData, + height, +}: BubbleChartProps): React.ReactElement { + const data = getDataWithPct(initialData); + + const [highlighted, setHighlighted] = useState(); + + return ( + + {({ measureRef, width }) => { + const { simulation, getRadius } = getSimulationConfig({ + width, + height, + data, + }); + + return ( + + {width > 0 && ( + <> + + + // slightly hacky solution here to a couple of issues: + // 1) if the value is zero there will be no bubble, so no label + // 2) if the bubble is really small (less than ~1%) the label won't fit + d.pct <= 0.01 ? null : ( + + {formatAsPct(d.pct)} + + ) + } + nodeSizeAccessor={getRadius} + nodeStyle={(d) => ({ + fill: + highlighted && highlighted.label !== d.label + ? highlightFade(d.color) + : d.color, + })} + nodes={data} + renderKey="label" + size={[width, height]} + /> + + + + + + )} + + ); + }} + + ); +} + +function getSimulationConfig({ + data, + height, + width, +}: { + data: (CategoricalChartRecord & { pct: number })[]; + height: number; + width: number; +}) { + const vizWidth = width - margin.left - margin.right; + const vizHeight = height - margin.top - margin.bottom; + + const maxRadius = Math.min( + // this would have a 100% bubble taking up the full height + vizHeight / 2, + // this would have a 100% bubble taking up a little less + // than full width, which gives us more flexibility for + // arbitrarily packing bubbles on small screens + vizWidth / 2.2 + ); + const rScale = scaleSqrt().domain([0, 1]).range([0, maxRadius]); + const getRadius = (record: SimulationNodeDatum | CategoricalChartRecord) => + // record picks up the pct field from the input data + rScale((record as { pct: number }).pct); + + // this is the most width bubbles would take up when aligned horizontally + // (the diameters of four equally sized circles) + const minWidthForHorizontalLayout = rScale(0.25) * 8; + const useHorizontalLayout = width >= minWidthForHorizontalLayout; + + // we will use this force object to position the circles + const simulation = forceSimulation(); + + if (useHorizontalLayout) { + // Pack all circles against the bottom edge and center them. To do this, + // we're going to have to do some geometry and fix their positions. + let centerXCoordinates: number[] = []; + data.forEach((record, i) => { + if (i === 0) { + centerXCoordinates.push(getRadius(record)); + return; + } + // skip over any zero-value records to calculate placement + // relative to nearest visible bubble; OK if there are no non-zero records + const prev = + data + .filter((...[, index]) => index < i) + .reverse() + .find(({ value }) => value > 0) || data[i - 1]; + const r1 = getRadius(prev); + const r2 = getRadius(record); + // This obscure next line is algebra with the Pythagorean Theorem; + // because the bottom edge of each circle is aligned with the bottom, + // and the hypotenuse is a line connecting the centers, we know that + // c is the sum of the radii and b is the difference between them. + // Then we just solve for a + const newLeftOffset = Math.sqrt( + (r1 + r2) ** 2 - Math.max(r2 - r1, r1 - r2) ** 2 + ); + centerXCoordinates.push(newLeftOffset); + }); + // convert the offsets from piecewise differences to absolute X values + centerXCoordinates = Array.from(cumsum(centerXCoordinates)); + // distribute any leftover space evenly between left and right + const groupWidth = + centerXCoordinates[centerXCoordinates.length - 1] + + getRadius(data[data.length - 1]); + const leftoverSpace = vizWidth - groupWidth; + centerXCoordinates = centerXCoordinates.map( + (val) => val + leftoverSpace / 2 + ); + + // all we need to do here is force the bubbles towards the x/y positions + // that we have already calculated + simulation + .force("x", forceX((d, i) => centerXCoordinates[i]).strength(1)) + .force("y", forceY((d) => vizHeight - getRadius(d)).strength(1)) + // overlaps are still possible, e.g. when a small bubble is + // between two large ones. this should prevent that + .force("collide", forceCollide().radius(getRadius)); + } else { + simulation + // this force keeps them from overflowing the container + .force( + "limit", + forceLimit() + .radius(getRadius) + .x0(margin.left) + .x1(width - margin.right) + .y0(margin.top) + .y1(height - margin.bottom) + ) + // this force prevents them from overlapping + .force("collide", forceCollide().radius(getRadius).strength(1)); + } + + return { simulation, getRadius }; +} diff --git a/spotlight-client/src/charts/ProportionalBar.test.tsx b/spotlight-client/src/charts/ProportionalBar.test.tsx index 90652c3f..9e903080 100644 --- a/spotlight-client/src/charts/ProportionalBar.test.tsx +++ b/spotlight-client/src/charts/ProportionalBar.test.tsx @@ -20,6 +20,8 @@ import React from "react"; import { renderWithStore } from "../testUtils"; import ProportionalBar from "./ProportionalBar"; +jest.mock("../MeasureWidth/MeasureWidth"); + test("renders data", () => { const testData = [ { label: "thing 1", color: "red", value: 10 }, diff --git a/spotlight-client/src/charts/ProportionalBar.tsx b/spotlight-client/src/charts/ProportionalBar.tsx index 3ed9895a..3599bf6b 100644 --- a/spotlight-client/src/charts/ProportionalBar.tsx +++ b/spotlight-client/src/charts/ProportionalBar.tsx @@ -24,7 +24,7 @@ import MeasureWidth from "../MeasureWidth"; import { animation, colors, zIndex } from "../UiLibrary"; import ColorLegend from "./ColorLegend"; import ResponsiveTooltipController from "./ResponsiveTooltipController"; -import { ItemToHighlight } from "./types"; +import { CategoricalChartRecord, ItemToHighlight } from "./types"; import { getDataWithPct, highlightFade } from "./utils"; const ProportionalBarContainer = styled.figure` @@ -70,7 +70,7 @@ const ProportionalBarLegendWrapper = styled.div` `; type ProportionalBarProps = { - data: { label: string; color: string; value: number }[]; + data: CategoricalChartRecord[]; height: number; highlighted?: ItemToHighlight; setHighlighted?: (item?: ItemToHighlight) => void; @@ -101,53 +101,59 @@ export default function ProportionalBar({ // figure caption does not seem to get consistently picked up as accessible name aria-label={title} > - - - title} - pieceClass="ProportionalBarChart__segment" - projection="horizontal" - rAccessor="value" - renderKey="label" - size={[width, height]} - style={(d: ValuesType) => ({ - fill: - highlighted && highlighted.label !== d.label - ? highlightFade(d.color) - : d.color, - })} - type="bar" - /> - - - - - {title} - {noData && ", No Data"} - - {showLegend && ( - - - - )} - + {width > 0 && ( + <> + + + title} + pieceClass="ProportionalBarChart__segment" + projection="horizontal" + rAccessor="value" + renderKey="label" + size={[width, height]} + style={(d: ValuesType) => ({ + fill: + highlighted && highlighted.label !== d.label + ? highlightFade(d.color) + : d.color, + })} + type="bar" + /> + + + + + {title} + {noData && ", No Data"} + + {showLegend && ( + + + + )} + + + )} )} diff --git a/spotlight-client/src/charts/types.ts b/spotlight-client/src/charts/types.ts index 38997059..10d047d9 100644 --- a/spotlight-client/src/charts/types.ts +++ b/spotlight-client/src/charts/types.ts @@ -46,3 +46,9 @@ export function isItemToHighlight( } export type ItemToDisplay = Pick; + +export type CategoricalChartRecord = { + label: string; + color: string; + value: number; +}; diff --git a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.test.ts b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.test.ts new file mode 100644 index 00000000..c38cc298 --- /dev/null +++ b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.test.ts @@ -0,0 +1,71 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { runInAction, when } from "mobx"; +import { DemographicView } from "../demographics"; +import { reactImmediately } from "../testUtils"; +import createMetricMapping from "./createMetricMapping"; +import DemographicsByCategoryMetric from "./DemographicsByCategoryMetric"; +import contentFixture from "./__fixtures__/tenant_content_exhaustive"; + +const testTenantId = "US_ND"; +const testMetricId = "PrisonReleaseTypeAggregate"; +const testMetadataMapping = { + [testMetricId]: contentFixture.metrics[testMetricId], +}; + +function getTestMetric() { + return createMetricMapping({ + localityLabelMapping: contentFixture.localities, + metadataMapping: testMetadataMapping, + tenantId: testTenantId, + }).get(testMetricId) as DemographicsByCategoryMetric; +} + +test("total data", async () => { + const metric = getTestMetric(); + + metric.populateAllRecords(); + + await when(() => Boolean(metric.dataSeries)); + + reactImmediately(() => { + expect(metric.dataSeries).toMatchSnapshot(); + }); + + expect.hasAssertions(); +}); + +test.each([["raceOrEthnicity"], ["gender"], ["ageBucket"]] as [ + Exclude +][])("%s data", async (demographicView) => { + const metric = getTestMetric(); + + metric.populateAllRecords(); + + await when(() => Boolean(metric.dataSeries)); + + runInAction(() => { + metric.demographicView = demographicView; + }); + + reactImmediately(() => { + expect(metric.dataSeries).toMatchSnapshot(); + }); + + expect.hasAssertions(); +}); diff --git a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts index f5d2e1fb..b2d40499 100644 --- a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts +++ b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts @@ -15,14 +15,27 @@ // along with this program. If not, see . // ============================================================================= -import { DataSeries } from "../charts/types"; -import { recordIsTotalByDimension } from "../demographics"; +import { computed, makeObservable } from "mobx"; +import { + getDemographicCategories, + recordIsTotalByDimension, +} from "../demographics"; import { DemographicsByCategoryRecord } from "../metricsApi"; -import Metric from "./Metric"; +import { colors } from "../UiLibrary"; +import Metric, { BaseMetricConstructorOptions } from "./Metric"; +import { DemographicCategoryRecords } from "./types"; export default class DemographicsByCategoryMetric extends Metric< DemographicsByCategoryRecord > { + constructor( + props: BaseMetricConstructorOptions + ) { + super(props); + + makeObservable(this, { dataSeries: computed }); + } + get records(): DemographicsByCategoryRecord[] | undefined { let recordsToReturn = this.getOrFetchRecords(); if (!recordsToReturn) return undefined; @@ -33,8 +46,29 @@ export default class DemographicsByCategoryMetric extends Metric< return recordsToReturn; } - // eslint-disable-next-line class-methods-use-this - get dataSeries(): DataSeries[] | null { - throw new Error("Method not implemented."); + get dataSeries(): DemographicCategoryRecords[] | null { + const { demographicView, records } = this; + if (!records || demographicView === "nofilter") return null; + + const categories = getDemographicCategories(demographicView); + + return categories.map(({ identifier, label }) => { + return { + label, + records: records + .filter((record) => + demographicView === "total" + ? true + : record[demographicView] === identifier + ) + .map((record, index) => { + return { + label: record.category, + color: colors.dataViz[index], + value: record.count, + }; + }), + }; + }); } } diff --git a/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts b/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts index c9786f5a..9be69324 100644 --- a/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts +++ b/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts @@ -82,6 +82,7 @@ afterEach(() => { const getMetric = async () => { const metric = new HistoricalPopulationBreakdownMetric({ ...contentFixture.metrics.PrisonPopulationHistorical, + id: "PrisonPopulationHistorical", tenantId: "US_ND", defaultDemographicView: "total", defaultLocalityId: undefined, diff --git a/spotlight-client/src/contentModels/Metric.ts b/spotlight-client/src/contentModels/Metric.ts index 16c82f6d..f8e7a74b 100644 --- a/spotlight-client/src/contentModels/Metric.ts +++ b/spotlight-client/src/contentModels/Metric.ts @@ -23,7 +23,7 @@ import { runInAction, } from "mobx"; import { ERROR_MESSAGES } from "../constants"; -import { LocalityLabels, TenantId } from "../contentApi/types"; +import { LocalityLabels, MetricTypeId, TenantId } from "../contentApi/types"; import { DemographicView } from "../demographics"; import { fetchMetrics, @@ -34,6 +34,7 @@ import { import { MetricRecord, CollectionMap } from "./types"; export type BaseMetricConstructorOptions = { + id: MetricTypeId; name: string; description: string; methodology: string; @@ -60,6 +61,8 @@ export type BaseMetricConstructorOptions = { */ export default abstract class Metric { // metadata properties + readonly id: MetricTypeId; + readonly description: string; readonly methodology: string; @@ -99,6 +102,7 @@ export default abstract class Metric { name, description, methodology, + id, tenantId, sourceFileName, dataTransformer, @@ -120,6 +124,7 @@ export default abstract class Metric { this.name = name; this.description = description; this.methodology = methodology; + this.id = id; // initialize data fetching this.tenantId = tenantId; diff --git a/spotlight-client/src/contentModels/PopulationBreakdownByLocationMetric.ts b/spotlight-client/src/contentModels/PopulationBreakdownByLocationMetric.ts index 06187ce7..8745e78e 100644 --- a/spotlight-client/src/contentModels/PopulationBreakdownByLocationMetric.ts +++ b/spotlight-client/src/contentModels/PopulationBreakdownByLocationMetric.ts @@ -29,15 +29,8 @@ import { } from "../metricsApi"; import { colors } from "../UiLibrary"; import Metric, { BaseMetricConstructorOptions } from "./Metric"; +import { DemographicCategoryRecords } from "./types"; -type DemographicCategoryRecords = { - viewName: string; - records: { - label: string; - color: string; - value: number; - }[]; -}; export default class PopulationBreakdownByLocationMetric extends Metric< PopulationBreakdownByLocationRecord > { @@ -75,7 +68,7 @@ export default class PopulationBreakdownByLocationMetric extends Metric< view !== "total" && view !== "nofilter" ).map((demographicView) => { return { - viewName: getDemographicViewLabel(demographicView), + label: getDemographicViewLabel(demographicView), records: getDemographicCategories(demographicView).map( ({ identifier, label }, index) => { let value = 0; diff --git a/spotlight-client/src/contentModels/__snapshots__/DemographicsByCategoryMetric.test.ts.snap b/spotlight-client/src/contentModels/__snapshots__/DemographicsByCategoryMetric.test.ts.snap new file mode 100644 index 00000000..ba608bd0 --- /dev/null +++ b/spotlight-client/src/contentModels/__snapshots__/DemographicsByCategoryMetric.test.ts.snap @@ -0,0 +1,411 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ageBucket data 1`] = ` +Array [ + Object { + "label": "<25", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 170, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 50, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 98, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 29, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 157, + }, + ], + }, + Object { + "label": "25-29", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 160, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 93, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 47, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 28, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 149, + }, + ], + }, + Object { + "label": "30-34", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 49, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 163, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 260, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 83, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 281, + }, + ], + }, + Object { + "label": "35-39", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 104, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 326, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 352, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 204, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 61, + }, + ], + }, + Object { + "label": "40<", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 123, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 62, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 212, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 196, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 37, + }, + ], + }, +] +`; + +exports[`gender data 1`] = ` +Array [ + Object { + "label": "Male", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 594, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 189, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 641, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 371, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 111, + }, + ], + }, + Object { + "label": "Female", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 92, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 494, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 533, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 309, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 157, + }, + ], + }, +] +`; + +exports[`raceOrEthnicity data 1`] = ` +Array [ + Object { + "label": "Native American", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 104, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 352, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 326, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 61, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 204, + }, + ], + }, + Object { + "label": "Black", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 344, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 200, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 319, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 60, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 102, + }, + ], + }, + Object { + "label": "Hispanic", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 22, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 37, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 125, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 116, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 72, + }, + ], + }, + Object { + "label": "White", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 203, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 188, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 35, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 118, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 60, + }, + ], + }, + Object { + "label": "Other", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 44, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 139, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 87, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 150, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 26, + }, + ], + }, +] +`; + +exports[`total data 1`] = ` +Array [ + Object { + "label": "Total", + "records": Array [ + Object { + "color": "#25636F", + "label": "Transfer out of system", + "value": 346, + }, + Object { + "color": "#D9A95F", + "label": "Sentence completion", + "value": 203, + }, + Object { + "color": "#BA4F4F", + "label": "Parole", + "value": 680, + }, + Object { + "color": "#4C6290", + "label": "Probation", + "value": 1088, + }, + Object { + "color": "#90AEB5", + "label": "Death", + "value": 1174, + }, + ], + }, +] +`; diff --git a/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap b/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap index 0dca5c97..768b7bd6 100644 --- a/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap +++ b/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap @@ -1878,28 +1878,28 @@ exports[`data fetching for metric ParoleRevocationsAggregate 1`] = ` Array [ Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 180, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 144, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 360, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 36, "gender": "ALL", "raceOrEthnicity": "ALL", @@ -2218,28 +2218,28 @@ exports[`data fetching for metric PrisonAdmissionReasonsCurrent 1`] = ` Array [ Object { "ageBucket": "ALL", - "category": "newAdmission", + "category": "New admissions", "count": 427, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "paroleRevoked", + "category": "Parole revocations", "count": 309, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "probationRevoked", + "category": "Probation revocations", "count": 245, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "other", + "category": "Other", "count": 481, "gender": "ALL", "raceOrEthnicity": "ALL", @@ -4902,35 +4902,35 @@ exports[`data fetching for metric PrisonReleaseTypeAggregate 1`] = ` Array [ Object { "ageBucket": "ALL", - "category": "transfer", + "category": "Transfer out of system", "count": 346, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "completion", + "category": "Sentence completion", "count": 203, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "parole", + "category": "Parole", "count": 680, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "probation", + "category": "Probation", "count": 1088, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "death", + "category": "Death", "count": 1174, "gender": "ALL", "raceOrEthnicity": "ALL", @@ -6870,28 +6870,28 @@ exports[`data fetching for metric ProbationRevocationsAggregate 1`] = ` Array [ Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 135, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 86, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 277, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 45, "gender": "ALL", "raceOrEthnicity": "ALL", @@ -7320,56 +7320,56 @@ exports[`demographic filter 1`] = ` Array [ Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 86, "gender": "FEMALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 282, "gender": "FEMALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 277, "gender": "FEMALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 72, "gender": "FEMALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 282, "gender": "MALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 86, "gender": "MALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 72, "gender": "MALE", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 108, "gender": "MALE", "raceOrEthnicity": "ALL", @@ -7381,140 +7381,140 @@ exports[`demographic filter 2`] = ` Array [ Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 252, "gender": "ALL", "raceOrEthnicity": "HISPANIC", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 252, "gender": "ALL", "raceOrEthnicity": "HISPANIC", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 252, "gender": "ALL", "raceOrEthnicity": "HISPANIC", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 72, "gender": "ALL", "raceOrEthnicity": "HISPANIC", }, Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 135, "gender": "ALL", "raceOrEthnicity": "WHITE", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 45, "gender": "ALL", "raceOrEthnicity": "WHITE", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 135, "gender": "ALL", "raceOrEthnicity": "WHITE", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 108, "gender": "ALL", "raceOrEthnicity": "WHITE", }, Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 403, "gender": "ALL", "raceOrEthnicity": "BLACK", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 45, "gender": "ALL", "raceOrEthnicity": "BLACK", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 252, "gender": "ALL", "raceOrEthnicity": "BLACK", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 72, "gender": "ALL", "raceOrEthnicity": "BLACK", }, Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 135, "gender": "ALL", "raceOrEthnicity": "OTHER", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 277, "gender": "ALL", "raceOrEthnicity": "OTHER", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 252, "gender": "ALL", "raceOrEthnicity": "OTHER", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 72, "gender": "ALL", "raceOrEthnicity": "OTHER", }, Object { "ageBucket": "ALL", - "category": "abscond", + "category": "Absconsion", "count": 282, "gender": "ALL", "raceOrEthnicity": "AMERICAN_INDIAN_ALASKAN_NATIVE", }, Object { "ageBucket": "ALL", - "category": "offend", + "category": "New offense", "count": 82, "gender": "ALL", "raceOrEthnicity": "AMERICAN_INDIAN_ALASKAN_NATIVE", }, Object { "ageBucket": "ALL", - "category": "technical", + "category": "Technical violation", "count": 482, "gender": "ALL", "raceOrEthnicity": "AMERICAN_INDIAN_ALASKAN_NATIVE", }, Object { "ageBucket": "ALL", - "category": "unknown", + "category": "Unknown type", "count": 72, "gender": "ALL", "raceOrEthnicity": "AMERICAN_INDIAN_ALASKAN_NATIVE", @@ -7526,140 +7526,140 @@ exports[`demographic filter 3`] = ` Array [ Object { "ageBucket": "<25", - "category": "abscond", + "category": "Absconsion", "count": 45, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "<25", - "category": "offend", + "category": "New offense", "count": 282, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "<25", - "category": "technical", + "category": "Technical violation", "count": 282, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "<25", - "category": "unknown", + "category": "Unknown type", "count": 108, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "25-29", - "category": "abscond", + "category": "Absconsion", "count": 282, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "25-29", - "category": "offend", + "category": "New offense", "count": 108, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "25-29", - "category": "technical", + "category": "Technical violation", "count": 282, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "25-29", - "category": "unknown", + "category": "Unknown type", "count": 108, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "40<", - "category": "abscond", + "category": "Absconsion", "count": 0, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "40<", - "category": "offend", + "category": "New offense", "count": 0, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "40<", - "category": "technical", + "category": "Technical violation", "count": 282, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "40<", - "category": "unknown", + "category": "Unknown type", "count": 108, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "30-34", - "category": "abscond", + "category": "Absconsion", "count": 277, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "30-34", - "category": "offend", + "category": "New offense", "count": 277, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "30-34", - "category": "technical", + "category": "Technical violation", "count": 277, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "30-34", - "category": "unknown", + "category": "Unknown type", "count": 0, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "35-39", - "category": "abscond", + "category": "Absconsion", "count": 86, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "35-39", - "category": "offend", + "category": "New offense", "count": 108, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "35-39", - "category": "technical", + "category": "Technical violation", "count": 108, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "35-39", - "category": "unknown", + "category": "Unknown type", "count": 45, "gender": "ALL", "raceOrEthnicity": "ALL", diff --git a/spotlight-client/src/contentModels/__snapshots__/PopulationBreakdownByLocationMetric.test.ts.snap b/spotlight-client/src/contentModels/__snapshots__/PopulationBreakdownByLocationMetric.test.ts.snap index 163e18be..bc508181 100644 --- a/spotlight-client/src/contentModels/__snapshots__/PopulationBreakdownByLocationMetric.test.ts.snap +++ b/spotlight-client/src/contentModels/__snapshots__/PopulationBreakdownByLocationMetric.test.ts.snap @@ -3,6 +3,7 @@ exports[`demographic data series 1`] = ` Array [ Object { + "label": "Race or Ethnicity", "records": Array [ Object { "color": "#25636F", @@ -30,9 +31,9 @@ Array [ "value": 12, }, ], - "viewName": "Race or Ethnicity", }, Object { + "label": "Gender", "records": Array [ Object { "color": "#25636F", @@ -45,9 +46,9 @@ Array [ "value": 67, }, ], - "viewName": "Gender", }, Object { + "label": "Age Group", "records": Array [ Object { "color": "#25636F", @@ -75,7 +76,6 @@ Array [ "value": 266, }, ], - "viewName": "Age Group", }, ] `; diff --git a/spotlight-client/src/contentModels/createMetricMapping.ts b/spotlight-client/src/contentModels/createMetricMapping.ts index 3aa553de..60d9d54e 100644 --- a/spotlight-client/src/contentModels/createMetricMapping.ts +++ b/spotlight-client/src/contentModels/createMetricMapping.ts @@ -97,6 +97,7 @@ export default function createMetricMapping({ metricType, new PopulationBreakdownByLocationMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: NOFILTER_KEY, defaultLocalityId: TOTAL_KEY, @@ -116,6 +117,7 @@ export default function createMetricMapping({ metricType, new SentenceTypeByLocationMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: TOTAL_KEY, @@ -134,6 +136,7 @@ export default function createMetricMapping({ metricType, new PopulationBreakdownByLocationMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: NOFILTER_KEY, defaultLocalityId: TOTAL_KEY, @@ -153,6 +156,7 @@ export default function createMetricMapping({ metricType, new PopulationBreakdownByLocationMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: NOFILTER_KEY, defaultLocalityId: TOTAL_KEY, @@ -172,6 +176,7 @@ export default function createMetricMapping({ metricType, new PopulationBreakdownByLocationMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: NOFILTER_KEY, defaultLocalityId: TOTAL_KEY, @@ -188,6 +193,7 @@ export default function createMetricMapping({ metricType, new HistoricalPopulationBreakdownMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -202,6 +208,7 @@ export default function createMetricMapping({ metricType, new HistoricalPopulationBreakdownMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -216,6 +223,7 @@ export default function createMetricMapping({ metricType, new HistoricalPopulationBreakdownMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -233,6 +241,7 @@ export default function createMetricMapping({ metricType, new ProgramParticipationCurrentMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: undefined, defaultLocalityId: NOFILTER_KEY, @@ -250,6 +259,7 @@ export default function createMetricMapping({ metricType, new ProgramParticipationCurrentMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: undefined, defaultLocalityId: NOFILTER_KEY, @@ -267,6 +277,7 @@ export default function createMetricMapping({ metricType, new SupervisionSuccessRateMonthlyMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: undefined, defaultLocalityId: TOTAL_KEY, @@ -284,6 +295,7 @@ export default function createMetricMapping({ metricType, new SupervisionSuccessRateMonthlyMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: undefined, defaultLocalityId: TOTAL_KEY, @@ -301,6 +313,7 @@ export default function createMetricMapping({ metricType, new SupervisionSuccessRateDemographicsMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: TOTAL_KEY, @@ -318,6 +331,7 @@ export default function createMetricMapping({ metricType, new SupervisionSuccessRateDemographicsMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: TOTAL_KEY, @@ -332,6 +346,7 @@ export default function createMetricMapping({ metricType, new DemographicsByCategoryMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -347,6 +362,7 @@ export default function createMetricMapping({ metricType, new DemographicsByCategoryMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -362,6 +378,7 @@ export default function createMetricMapping({ metricType, new DemographicsByCategoryMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -376,6 +393,7 @@ export default function createMetricMapping({ metricType, new DemographicsByCategoryMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -390,6 +408,7 @@ export default function createMetricMapping({ metricType, new RecidivismRateMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -404,6 +423,7 @@ export default function createMetricMapping({ metricType, new RecidivismRateMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, @@ -418,6 +438,7 @@ export default function createMetricMapping({ metricType, new DemographicsByCategoryMetric({ ...metadata, + id: metricType, tenantId, defaultDemographicView: "total", defaultLocalityId: undefined, diff --git a/spotlight-client/src/contentModels/types.ts b/spotlight-client/src/contentModels/types.ts index 9c38dcf5..106c96cb 100644 --- a/spotlight-client/src/contentModels/types.ts +++ b/spotlight-client/src/contentModels/types.ts @@ -56,6 +56,15 @@ export type MetricRecord = export type MetricMapping = Map>; +export type DemographicCategoryRecords = { + label: string; + records: { + label: string; + color: string; + value: number; + }[]; +}; + // ======================================= // Narrative types // ======================================= diff --git a/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts b/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts index d5c1677c..fde5deac 100644 --- a/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts +++ b/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts @@ -48,10 +48,10 @@ function getCategoryTransposeFunction( } const revocationReasonFields = [ - { categoryLabel: "abscond", fieldName: "absconsion_count" }, - { categoryLabel: "offend", fieldName: "new_crime_count" }, - { categoryLabel: "technical", fieldName: "technical_count" }, - { categoryLabel: "unknown", fieldName: "unknown_count" }, + { categoryLabel: "Absconsion", fieldName: "absconsion_count" }, + { categoryLabel: "New offense", fieldName: "new_crime_count" }, + { categoryLabel: "Technical violation", fieldName: "technical_count" }, + { categoryLabel: "Unknown type", fieldName: "unknown_count" }, ]; export function probationRevocationReasons( @@ -71,13 +71,13 @@ export function paroleRevocationReasons( } const prisonAdmissionFields = [ - { categoryLabel: "newAdmission", fieldName: "new_admission_count" }, - { categoryLabel: "paroleRevoked", fieldName: "parole_revocation_count" }, + { categoryLabel: "New admissions", fieldName: "new_admission_count" }, + { categoryLabel: "Parole revocations", fieldName: "parole_revocation_count" }, { - categoryLabel: "probationRevoked", + categoryLabel: "Probation revocations", fieldName: "probation_revocation_count", }, - { categoryLabel: "other", fieldName: "other_count" }, + { categoryLabel: "Other", fieldName: "other_count" }, ]; export function prisonAdmissionReasons( @@ -87,11 +87,17 @@ export function prisonAdmissionReasons( } const prisonReleaseFields = [ - { categoryLabel: "transfer", fieldName: "external_transfer_count" }, - { categoryLabel: "completion", fieldName: "sentence_completion_count" }, - { categoryLabel: "parole", fieldName: "parole_count" }, - { categoryLabel: "probation", fieldName: "probation_count" }, - { categoryLabel: "death", fieldName: "death_count" }, + { + categoryLabel: "Transfer out of system", + fieldName: "external_transfer_count", + }, + { + categoryLabel: "Sentence completion", + fieldName: "sentence_completion_count", + }, + { categoryLabel: "Parole", fieldName: "parole_count" }, + { categoryLabel: "Probation", fieldName: "probation_count" }, + { categoryLabel: "Death", fieldName: "death_count" }, ]; export function prisonReleaseTypes( diff --git a/yarn.lock b/yarn.lock index 2f29ebfb..c2d86bb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1988,6 +1988,11 @@ resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-2.0.1.tgz#570ea7f8b853461301804efa52bd790a640a26db" integrity sha512-u7LTCL7RnaavFSmob2rIAJLNwu50i6gFwY9cHFr80BrQURYQBRkJ+Yv47nA3Fm7FeRhdWTiVTeqvSeOuMAOzBQ== +"@types/d3-force@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-2.1.0.tgz#6a2210f04d02a0862c6b069de91bad904143e7b5" + integrity sha512-LGDtC2YADu8OBniq9EBx/MOsXsMcJbEkmfSpXuz6oVdRamB+3CLCiq5EKFPEILGZQckkilGFq1ZTJ7kc289k+Q== + "@types/d3-format@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-2.0.0.tgz#607d261cb268f0a027f100575491031539a40ee6" @@ -4799,7 +4804,7 @@ d3-force@^1.0.2: d3-quadtree "1" d3-timer "1" -d3-force@^2.0.1: +d3-force@^2.0.1, d3-force@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937" integrity sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==