diff --git a/spotlight-client/.eslintrc.json b/spotlight-client/.eslintrc.json index c4d8b0ce..f37ea4ca 100644 --- a/spotlight-client/.eslintrc.json +++ b/spotlight-client/.eslintrc.json @@ -67,7 +67,13 @@ ], // support typescript as well as javascript file extensions - "react/jsx-filename-extension": ["error", { "extensions": [".tsx", ".js"] }] + "react/jsx-filename-extension": [ + "error", + { "extensions": [".tsx", ".js"] } + ], + + // this can conflict with Prettier + "react/jsx-indent": "off" }, "settings": { "import/resolver": { diff --git a/spotlight-client/src/DataStore/UiStore.ts b/spotlight-client/src/DataStore/UiStore.ts index 0db4284f..cde44378 100644 --- a/spotlight-client/src/DataStore/UiStore.ts +++ b/spotlight-client/src/DataStore/UiStore.ts @@ -16,15 +16,14 @@ // ============================================================================= import { makeAutoObservable, observable } from "mobx"; -import { ProjectedDataPoint } from "../charts/types"; import type RootStore from "./RootStore"; export default class UiStore { rootStore: RootStore; - tooltipMobileData?: ProjectedDataPoint; + tooltipMobileData?: Record; - renderTooltipMobile?: (props: ProjectedDataPoint) => React.ReactNode; + renderTooltipMobile?: (props: Record) => React.ReactNode; constructor({ rootStore }: { rootStore: RootStore }) { makeAutoObservable(this, { diff --git a/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx b/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx index f5f0f934..0800d625 100644 --- a/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx +++ b/spotlight-client/src/MetricVizMapper/MetricVizMapper.tsx @@ -24,6 +24,7 @@ import PopulationBreakdownByLocationMetric from "../contentModels/PopulationBrea import VizPopulationBreakdownByLocation from "../VizPopulationBreakdownByLocation"; import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; import VizDemographicsByCategory from "../VizDemographicsByCategory"; +import VizPrisonStayLengths from "../VizPrisonStayLengths"; type MetricVizMapperProps = { metric: Metric; @@ -36,10 +37,10 @@ const MetricVizMapper: React.FC = ({ metric }) => { if (metric instanceof PopulationBreakdownByLocationMetric) { return ; } - if ( - metric instanceof DemographicsByCategoryMetric && - metric.id !== "PrisonStayLengthAggregate" - ) { + if (metric instanceof DemographicsByCategoryMetric) { + if (metric.id === "PrisonStayLengthAggregate") { + return ; + } return ; } return

Placeholder for {metric.name}

; diff --git a/spotlight-client/src/NoMetricData/NoMetricData.tsx b/spotlight-client/src/NoMetricData/NoMetricData.tsx index 6278b8ee..4c728ab7 100644 --- a/spotlight-client/src/NoMetricData/NoMetricData.tsx +++ b/spotlight-client/src/NoMetricData/NoMetricData.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . // ============================================================================= +import { observer } from "mobx-react-lite"; import React from "react"; import Metric from "../contentModels/Metric"; import { MetricRecord } from "../contentModels/types"; @@ -30,4 +31,4 @@ const NoMetricData: React.FC = ({ metric }) => { return ; }; -export default NoMetricData; +export default observer(NoMetricData); diff --git a/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx b/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx index d3e10327..2fafbdfb 100644 --- a/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx +++ b/spotlight-client/src/VizDemographicsByCategory/VizDemographicsByCategory.tsx @@ -17,12 +17,12 @@ import { observer } from "mobx-react-lite"; import { rem } from "polished"; -import React, { useState } from "react"; +import React 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 { BubbleChart, ProportionalBar } from "../charts"; +import { useHighlightedItem } from "../charts/utils"; import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; import DemographicFilterSelect from "../DemographicFilterSelect"; import FiltersWrapper from "../FiltersWrapper"; @@ -51,7 +51,7 @@ type VizDemographicsByCategoryProps = { const VizDemographicsByCategory: React.FC = ({ metric, }) => { - const [highlighted, setHighlighted] = useState(); + const { highlighted, setHighlighted } = useHighlightedItem(); const { demographicView, dataSeries } = metric; diff --git a/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx new file mode 100644 index 00000000..82793487 --- /dev/null +++ b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.test.tsx @@ -0,0 +1,153 @@ +// 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, + waitForElementToBeRemoved, + 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 { colors } from "../UiLibrary"; +import VizPrisonStayLengths from "./VizPrisonStayLengths"; + +jest.mock("../MeasureWidth/MeasureWidth"); + +let metric: DemographicsByCategoryMetric; + +beforeEach(() => { + runInAction(() => { + DataStore.tenantStore.currentTenantId = "US_ND"; + }); + reactImmediately(() => { + const metricToTest = DataStore.tenant?.metrics.get( + "PrisonStayLengthAggregate" + ); + // it will be + if (metricToTest instanceof DemographicsByCategoryMetric) { + metric = metricToTest; + } + }); +}); + +afterEach(() => { + runInAction(() => { + // reset data store + metric.demographicView = "total"; + DataStore.tenantStore.currentTenantId = undefined; + }); +}); + +test("loading", () => { + renderWithStore(); + expect(screen.getByText(/loading/i)).toBeVisible(); +}); + +test("total chart", async () => { + renderWithStore(); + + await when(() => !metric.isLoading); + + const chart = screen.getByRole("group", { name: "7 bars in a bar chart" }); + expect(chart).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "<1 year bar value 15%" }) + ).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "1–2 bar value 1%" }) + ).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "2–3 bar value 17%" }) + ).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "3–5 bar value 31%" }) + ).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "5–10 bar value 26%" }) + ).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "10–20 bar value 1%" }) + ).toBeVisible(); + expect( + within(chart).getByRole("img", { name: "20+ bar value 9%" }) + ).toBeVisible(); +}); + +test("demographic charts", async () => { + renderWithStore(); + + await when(() => !metric.isLoading); + + const totalChart = screen.getByRole("group", { + name: "7 bars in a bar chart", + }); + + 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 waitForElementToBeRemoved(totalChart); + + const raceCharts = screen.getAllByRole("group", { + name: "7 bars in a bar chart", + }); + expect(raceCharts.length).toBe(5); + + fireEvent.click(menuButton); + fireEvent.click(screen.getByRole("option", { name: "Gender" })); + + // pause for animated transition + await waitForElementToBeRemoved(raceCharts[0]); + + const genderCharts = screen.getAllByRole("group", { + name: "7 bars in a bar chart", + }); + expect(genderCharts.length).toBe(2); + + fireEvent.click(menuButton); + fireEvent.click(screen.getByRole("option", { name: "Age Group" })); + + // pause for animated transition + await waitForElementToBeRemoved(genderCharts[0]); + + expect( + screen.getAllByRole("group", { name: "7 bars in a bar chart" }).length + ).toBe(5); +}); + +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" }); + + within(chart) + .getAllByRole("img") + .forEach((el) => + expect(el).toHaveStyle(`fill: ${colors.dataVizNamed.get("teal")}`) + ); + + expect.hasAssertions(); +}); diff --git a/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.tsx b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.tsx new file mode 100644 index 00000000..a3e9251a --- /dev/null +++ b/spotlight-client/src/VizPrisonStayLengths/VizPrisonStayLengths.tsx @@ -0,0 +1,138 @@ +// 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 React from "react"; +import Measure from "react-measure"; +import { animated, useSpring, useTransition } from "react-spring/web.cjs"; +import styled from "styled-components/macro"; +import { + CommonDataPoint, + BarChartTrellis, + singleChartHeight, + TooltipContentFunction, +} from "../charts"; +import DemographicsByCategoryMetric from "../contentModels/DemographicsByCategoryMetric"; +import DemographicFilterSelect from "../DemographicFilterSelect"; +import FiltersWrapper from "../FiltersWrapper"; +import { prisonStayLengthFields } from "../metricsApi"; +import NoMetricData from "../NoMetricData"; + +const ChartsWrapper = styled.div` + position: relative; +`; + +const getTooltipProps: TooltipContentFunction = (columnData) => { + const { + summary: [ + { + data: { label, pct, value }, + }, + ], + } = columnData as { + // can't find any Semiotic type definition that describes what is actually + // passed to this function, but the part we care about looks like this + summary: { data: CommonDataPoint }[]; + }; + + return { + title: `${label}${ + // special case: the first category already has "year" in it + label !== prisonStayLengthFields[0].categoryLabel ? " years" : "" + }`, + records: [ + { + pct, + value, + }, + ], + }; +}; + +type VizPrisonStayLengthsProps = { + metric: DemographicsByCategoryMetric; +}; + +const VizPrisonStayLengths: React.FC = ({ + metric, +}) => { + const { dataSeries, demographicView } = metric; + + const [chartContainerStyles, setChartContainerStyles] = useSpring(() => ({ + from: { height: singleChartHeight }, + height: singleChartHeight, + config: { friction: 40, tension: 220, 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 && ( + + ) + } + + + ))} + + + )} + + ); + } + + return ; +}; + +export default observer(VizPrisonStayLengths); diff --git a/spotlight-client/src/VizPrisonStayLengths/index.ts b/spotlight-client/src/VizPrisonStayLengths/index.ts new file mode 100644 index 00000000..fcce007a --- /dev/null +++ b/spotlight-client/src/VizPrisonStayLengths/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 "./VizPrisonStayLengths"; diff --git a/spotlight-client/src/charts/BarChartTrellis.test.tsx b/spotlight-client/src/charts/BarChartTrellis.test.tsx new file mode 100644 index 00000000..df45c904 --- /dev/null +++ b/spotlight-client/src/charts/BarChartTrellis.test.tsx @@ -0,0 +1,72 @@ +// 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 { screen } from "@testing-library/react"; +import React from "react"; +import { renderWithStore } from "../testUtils"; +import { BarChartTrellis } from "./BarChartTrellis"; + +jest.mock("../MeasureWidth/MeasureWidth"); + +const mockGetTooltipProps = jest.fn(); +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: "Group 2", + records: [ + { label: "Category A", color: "red", value: 80, pct: 0.4 }, + { label: "Category B", color: "blue", value: 120, pct: 0.6 }, + ], + }, +]; + +test("renders charts", () => { + renderWithStore( + + ); + + expect( + screen.getAllByRole("group", { name: "2 bars in a bar chart" }).length + ).toBe(2); + + expect( + screen.getByRole("img", { name: "Category A bar value 30%" }) + ).toHaveStyle("fill: red"); + expect( + screen.getByRole("img", { name: "Category A bar value 40%" }) + ).toHaveStyle("fill: red"); + expect( + screen.getByRole("img", { name: "Category B bar value 70%" }) + ).toHaveStyle("fill: blue"); + expect( + screen.getByRole("img", { name: "Category B bar value 60%" }) + ).toHaveStyle("fill: blue"); +}); + +test("all charts have same Y axis range", () => { + renderWithStore( + + ); + + expect(screen.getAllByLabelText("left axis from 0% to 100%").length).toBe(2); +}); diff --git a/spotlight-client/src/charts/BarChartTrellis.tsx b/spotlight-client/src/charts/BarChartTrellis.tsx new file mode 100644 index 00000000..87c7e708 --- /dev/null +++ b/spotlight-client/src/charts/BarChartTrellis.tsx @@ -0,0 +1,150 @@ +// 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, { useState, useCallback } from "react"; +import { OrdinalFrame } from "semiotic"; +import styled from "styled-components/macro"; +import ChartWrapper from "./ChartWrapper"; +import ResponsiveTooltipController, { + ResponsiveTooltipControllerProps, +} from "./ResponsiveTooltipController"; +import { formatAsPct } from "../utils"; +import { getDataWithPct, highlightFade } from "./utils"; +import { animation } from "../UiLibrary"; +import { CategoricalChartRecord, CommonDataPoint } from "./types"; +import MeasureWidth from "../MeasureWidth"; + +export const singleChartHeight = 300; + +const MARGIN = { top: 56, bottom: 64, left: 48, right: 0 }; + +const ChartTitle = styled.text` + font-weight: 500; + letter-spacing: -0.01em; + text-anchor: start; +`; + +const ColumnLabel = styled.text` + text-anchor: middle; +`; + +const BarAxisLabel = styled.text` + text-anchor: middle; +`; + +type BarChartData = { + label: string; + records: CategoricalChartRecord[]; +}; + +type BarChartTrellisProps = { + barAxisLabel?: string; + data: BarChartData[]; + formatBarLabel?: (label: string) => string; + getTooltipProps: ResponsiveTooltipControllerProps["getTooltipProps"]; +}; + +/** + * Renders multiple bar charts (one per series in data) + * with identical Y axis ranges and cross-highlighting. + */ +export function BarChartTrellis({ + barAxisLabel, + data, + formatBarLabel = (label) => label, + getTooltipProps, +}: BarChartTrellisProps): React.ReactElement { + const [highlightedLabel, setHighlightedLabel] = useState(); + + // ResponsiveTooltipController expects this to be a stable reference + const setHighlighted = useCallback( + (d) => setHighlightedLabel(d ? d.column.name : undefined), + [setHighlightedLabel] + ); + + 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 3662c7f8..5ccee7f1 100644 --- a/spotlight-client/src/charts/BubbleChart.tsx +++ b/spotlight-client/src/charts/BubbleChart.tsx @@ -26,14 +26,14 @@ import { import forceLimit from "d3-force-limit"; import { scaleSqrt } from "d3-scale"; import { rem } from "polished"; -import React, { useState } from "react"; +import React 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 { useHighlightedItem, getDataWithPct, highlightFade } from "./utils"; +import { CategoricalChartRecord } from "./types"; import { animation, colors, typefaces } from "../UiLibrary"; import MeasureWidth from "../MeasureWidth"; @@ -80,7 +80,7 @@ export default function BubbleChart({ }: BubbleChartProps): React.ReactElement { const data = getDataWithPct(initialData); - const [highlighted, setHighlighted] = useState(); + const { highlighted, setHighlighted } = useHighlightedItem(); return ( diff --git a/spotlight-client/src/charts/ProportionalBar.tsx b/spotlight-client/src/charts/ProportionalBar.tsx index 3599bf6b..a0410341 100644 --- a/spotlight-client/src/charts/ProportionalBar.tsx +++ b/spotlight-client/src/charts/ProportionalBar.tsx @@ -16,7 +16,7 @@ // ============================================================================= import { sum } from "d3-array"; -import React, { useState } from "react"; +import React from "react"; import OrdinalFrame from "semiotic/lib/OrdinalFrame"; import styled from "styled-components/macro"; import { ValuesType } from "utility-types"; @@ -25,7 +25,7 @@ import { animation, colors, zIndex } from "../UiLibrary"; import ColorLegend from "./ColorLegend"; import ResponsiveTooltipController from "./ResponsiveTooltipController"; import { CategoricalChartRecord, ItemToHighlight } from "./types"; -import { getDataWithPct, highlightFade } from "./utils"; +import { useHighlightedItem, getDataWithPct, highlightFade } from "./utils"; const ProportionalBarContainer = styled.figure` width: 100%; @@ -86,7 +86,10 @@ export default function ProportionalBar({ showLegend = true, title, }: ProportionalBarProps): React.ReactElement { - const [localHighlighted, setLocalHighlighted] = useState(); + const { + highlighted: localHighlighted, + setHighlighted: setLocalHighlighted, + } = useHighlightedItem(); const dataWithPct = getDataWithPct(data); const noData = data.length === 0 || sum(data.map(({ value }) => value)) === 0; diff --git a/spotlight-client/src/charts/ResponsiveTooltipController.tsx b/spotlight-client/src/charts/ResponsiveTooltipController.tsx index b9eaf7da..b0947b37 100644 --- a/spotlight-client/src/charts/ResponsiveTooltipController.tsx +++ b/spotlight-client/src/charts/ResponsiveTooltipController.tsx @@ -22,19 +22,18 @@ import React, { useEffect, useState } from "react"; import { AnnotationType } from "semiotic/lib/types/annotationTypes"; import { OrdinalFrameProps } from "semiotic/lib/types/ordinalTypes"; import { XYFrameProps } from "semiotic/lib/types/xyTypes"; -import Tooltip, { TooltipContentProps } from "../Tooltip"; -import { ItemToHighlight, ProjectedDataPoint } from "./types"; +import Tooltip from "../Tooltip"; +import { CommonDataPoint, TooltipContentFunction } from "./types"; import { useDataStore } from "../StoreProvider"; /** * Default tooltip content generator. Provides a title and a single * data point with optional percentage. Good enough for most charts. + * Expects `point` to resemble `ProjectedDataPoint`, but for consistency + * with the Semiotic interface it does not actually enforce the argument type. */ -function chartDataToTooltipProps({ - label, - value, - pct, -}: ProjectedDataPoint): TooltipContentProps { +const chartDataToTooltipProps: TooltipContentFunction = (point) => { + const { label, value, pct } = point as CommonDataPoint; return { title: label, records: [ @@ -44,21 +43,20 @@ function chartDataToTooltipProps({ }, ], }; -} +}; // in practice it should be one or the other but it's not straightforward // to discriminate them at compile time type SemioticChildProps = Partial & Partial; export type ResponsiveTooltipControllerProps = { - customHoverBehavior?: (record?: ProjectedDataPoint) => void; - getTooltipProps?: (point: ProjectedDataPoint) => TooltipContentProps; + customHoverBehavior?: (record?: Record) => void; + getTooltipProps?: TooltipContentFunction; hoverAnnotation?: | XYFrameProps["hoverAnnotation"] | OrdinalFrameProps["hoverAnnotation"]; pieceHoverAnnotation?: boolean; - render?: (props: SemioticChildProps) => React.ReactElement; - setHighlighted?: (item?: ItemToHighlight) => void; + setHighlighted?: (item?: Record) => void; }; /** @@ -73,7 +71,6 @@ const ResponsiveTooltipController: React.FC = getTooltipProps = chartDataToTooltipProps, hoverAnnotation, pieceHoverAnnotation, - render, setHighlighted, customHoverBehavior, }) => { @@ -102,7 +99,7 @@ const ResponsiveTooltipController: React.FC = // childProps are props that Semiotic will recognize; non-Semiotic children // should implement the same API if they want to use this controller - const tooltipContent = (d: ProjectedDataPoint) => ( + const tooltipContent = (d: Record) => ( ); const renderNull = () => null; @@ -125,15 +122,15 @@ const ResponsiveTooltipController: React.FC = if (pieceHoverAnnotation) childProps.pieceHoverAnnotation = pieceHoverAnnotation; - childProps.customClickBehavior = (d?: ProjectedDataPoint) => { + childProps.customClickBehavior = (d?: Record) => { if (enableTouchTooltip) { action("update info panel", () => { uiStore.tooltipMobileData = d; uiStore.renderTooltipMobile = tooltipContent; })(); - if (setHighlighted) { - setHighlighted(d); - } + + if (setHighlighted) setHighlighted(d); + if (Array.isArray(hoverAnnotation)) { // if there is hover behavior other than the tooltip, we want to preserve it const additionalHoverAnnotations: AnnotationType[] = hoverAnnotation @@ -159,16 +156,12 @@ const ResponsiveTooltipController: React.FC = } }; - childProps.customHoverBehavior = (d?: ProjectedDataPoint) => { - if (setHighlighted) { - setHighlighted(d); - } + childProps.customHoverBehavior = (d?: Record) => { + if (setHighlighted) setHighlighted(d); + if (customHoverBehavior) customHoverBehavior(d); }; - if (render) { - return render(childProps); - } if (children) { return ( <> diff --git a/spotlight-client/src/charts/WindowedTimeSeries.tsx b/spotlight-client/src/charts/WindowedTimeSeries.tsx index 000d386d..aab472d8 100644 --- a/spotlight-client/src/charts/WindowedTimeSeries.tsx +++ b/spotlight-client/src/charts/WindowedTimeSeries.tsx @@ -24,11 +24,11 @@ import styled from "styled-components/macro"; import { animation, colors } from "../UiLibrary"; import { formatAsNumber } from "../utils"; import BaseChartWrapper from "./ChartWrapper"; -import { getDataWithPct, highlightFade } from "./utils"; +import { getDataWithPct, highlightFade, useHighlightedItem } from "./utils"; import ColorLegend from "./ColorLegend"; import XHoverController from "./XHoverController"; import { HistoricalPopulationBreakdownRecord } from "../metricsApi"; -import { DataSeries, ItemToHighlight } from "./types"; +import { DataSeries } from "./types"; import MeasureWidth from "../MeasureWidth"; const CHART_HEIGHT = 430; @@ -77,7 +77,7 @@ const WindowedTimeSeries: React.FC<{ defaultRangeStart?: Date; setTimeRangeId: (id: WindowSizeId) => void; }> = ({ data, defaultRangeEnd, defaultRangeStart, setTimeRangeId }) => { - const [highlighted, setHighlighted] = useState(); + const { highlighted, setHighlighted } = useHighlightedItem(); const [dateRangeStart, setDateRangeStart] = useState(); const [dateRangeEnd, setDateRangeEnd] = useState(); const { isMobile } = useBreakpoint(); diff --git a/spotlight-client/src/charts/index.ts b/spotlight-client/src/charts/index.ts index c1b20837..c605eb39 100644 --- a/spotlight-client/src/charts/index.ts +++ b/spotlight-client/src/charts/index.ts @@ -15,6 +15,8 @@ // along with this program. If not, see . // ============================================================================= +export * from "./BarChartTrellis"; +export { default as BubbleChart } from "./BubbleChart"; export { default as ProportionalBar } from "./ProportionalBar"; export { default as WindowedTimeSeries } from "./WindowedTimeSeries"; export * from "./WindowedTimeSeries"; diff --git a/spotlight-client/src/charts/types.ts b/spotlight-client/src/charts/types.ts index 10d047d9..23e9e54b 100644 --- a/spotlight-client/src/charts/types.ts +++ b/spotlight-client/src/charts/types.ts @@ -15,8 +15,8 @@ // along with this program. If not, see . // ============================================================================= -import { ProjectedPoint } from "semiotic/lib/types/generalTypes"; import { MetricRecord } from "../contentModels/types"; +import { TooltipContentProps } from "../Tooltip"; export type DataSeries = { label: string; @@ -29,13 +29,11 @@ export type DataSeries = { * and properties derived from our DataSeries format that Semiotic * attaches to the object */ -export type ProjectedDataPoint = ProjectedPoint & { +export type CommonDataPoint = { label: DataSeries["label"]; color: DataSeries["color"]; value: number; pct?: number; - date?: Date; - [key: string]: unknown; }; export type ItemToHighlight = Pick; @@ -52,3 +50,10 @@ export type CategoricalChartRecord = { color: string; value: number; }; + +export type TooltipContentFunction = ( + // this point comes from Semiotic and is not strongly typed. + // There is really no consistent shape to it, it depends on + // the chart type and what the original data looked like + point: Record +) => TooltipContentProps; diff --git a/spotlight-client/src/charts/utils.ts b/spotlight-client/src/charts/utils.ts index dee96580..e5af38d0 100644 --- a/spotlight-client/src/charts/utils.ts +++ b/spotlight-client/src/charts/utils.ts @@ -18,7 +18,9 @@ import { sum } from "d3-array"; import { color } from "d3-color"; import { interpolateRgb } from "d3-interpolate"; +import { useCallback, useState } from "react"; import { colors } from "../UiLibrary"; +import { isItemToHighlight, ItemToHighlight } from "./types"; /** * Given a series of records, sums up their values and computes the value of each @@ -58,3 +60,29 @@ export function highlightFade( // the ramp goes from 0 to 1 with values analogous to opacity return interpolateRgb(colors.background, baseColor)(FADE_AMOUNT); } + +export function useHighlightedItem( + initialValue?: ItemToHighlight +): { + // object instead of tuple because the TS tuple syntax is unsupported + // in our version of react-scripts + // https://github.com/facebook/create-react-app/issues/9515 + highlighted: ItemToHighlight | undefined; + setHighlighted: (arg?: Record) => void; +} { + const [highlighted, setHighlighted] = useState( + initialValue + ); + const setHighlightedWithTypeCheck = useCallback( + (arg) => { + if (isItemToHighlight(arg) || typeof arg === "undefined") { + setHighlighted(arg); + } else { + throw new Error("unexpected data type; cannot highlight"); + } + }, + [setHighlighted] + ); + + return { highlighted, setHighlighted: setHighlightedWithTypeCheck }; +} diff --git a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts index b2d40499..d35be0c4 100644 --- a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts +++ b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts @@ -28,11 +28,18 @@ import { DemographicCategoryRecords } from "./types"; export default class DemographicsByCategoryMetric extends Metric< DemographicsByCategoryRecord > { + // consumers can override to make all records the same color + private readonly color?: string; + constructor( - props: BaseMetricConstructorOptions + props: BaseMetricConstructorOptions & { + color?: string; + } ) { super(props); + this.color = props.color; + makeObservable(this, { dataSeries: computed }); } @@ -47,7 +54,7 @@ export default class DemographicsByCategoryMetric extends Metric< } get dataSeries(): DemographicCategoryRecords[] | null { - const { demographicView, records } = this; + const { color, demographicView, records } = this; if (!records || demographicView === "nofilter") return null; const categories = getDemographicCategories(demographicView); @@ -64,7 +71,7 @@ export default class DemographicsByCategoryMetric extends Metric< .map((record, index) => { return { label: record.category, - color: colors.dataViz[index], + color: color || colors.dataViz[index], value: record.count, }; }), diff --git a/spotlight-client/src/contentModels/ProgramParticipationCurrentMetric.ts b/spotlight-client/src/contentModels/ProgramParticipationCurrentMetric.ts index 8d6b7806..44f24614 100644 --- a/spotlight-client/src/contentModels/ProgramParticipationCurrentMetric.ts +++ b/spotlight-client/src/contentModels/ProgramParticipationCurrentMetric.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { DataSeries } from "../charts/types"; +import { DataSeries } from "../charts"; import { ProgramParticipationCurrentRecord } from "../metricsApi"; import Metric from "./Metric"; diff --git a/spotlight-client/src/contentModels/RecidivismRateMetric.ts b/spotlight-client/src/contentModels/RecidivismRateMetric.ts index 058beece..1cdc5c77 100644 --- a/spotlight-client/src/contentModels/RecidivismRateMetric.ts +++ b/spotlight-client/src/contentModels/RecidivismRateMetric.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { DataSeries } from "../charts/types"; +import { DataSeries } from "../charts"; import { recordIsTotalByDimension } from "../demographics"; import { RecidivismRateRecord } from "../metricsApi"; import Metric from "./Metric"; diff --git a/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts b/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts index 5ed48a7b..0a787c14 100644 --- a/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts +++ b/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { DataSeries } from "../charts/types"; +import { DataSeries } from "../charts"; import { recordIsTotalByDimension } from "../demographics"; import { recordMatchesLocality, diff --git a/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts b/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts index fb6e4ccf..902332f7 100644 --- a/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts +++ b/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { DataSeries } from "../charts/types"; +import { DataSeries } from "../charts"; import { recordIsTotalByDimension } from "../demographics"; import { recordMatchesLocality, diff --git a/spotlight-client/src/contentModels/SupervisionSuccessRateMonthlyMetric.ts b/spotlight-client/src/contentModels/SupervisionSuccessRateMonthlyMetric.ts index 593e2260..b8d65fc5 100644 --- a/spotlight-client/src/contentModels/SupervisionSuccessRateMonthlyMetric.ts +++ b/spotlight-client/src/contentModels/SupervisionSuccessRateMonthlyMetric.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { DataSeries } from "../charts/types"; +import { DataSeries } from "../charts"; import { recordMatchesLocality, SupervisionSuccessRateMonthlyRecord, diff --git a/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap b/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap index 768b7bd6..2d5ae8da 100644 --- a/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap +++ b/spotlight-client/src/contentModels/__snapshots__/Metric.test.ts.snap @@ -4942,49 +4942,49 @@ exports[`data fetching for metric PrisonStayLengthAggregate 1`] = ` Array [ Object { "ageBucket": "ALL", - "category": "lessThanOne", + "category": "<1 year", "count": 346, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "oneTwo", + "category": "1–2", "count": 12, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "twoThree", + "category": "2–3", "count": 404, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "threeFive", + "category": "3–5", "count": 729, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "fiveTen", + "category": "5–10", "count": 609, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "tenTwenty", + "category": "10–20", "count": 31, "gender": "ALL", "raceOrEthnicity": "ALL", }, Object { "ageBucket": "ALL", - "category": "moreThanTwenty", + "category": "20+", "count": 210, "gender": "ALL", "raceOrEthnicity": "ALL", diff --git a/spotlight-client/src/contentModels/createMetricMapping.ts b/spotlight-client/src/contentModels/createMetricMapping.ts index 60d9d54e..0fdd48b2 100644 --- a/spotlight-client/src/contentModels/createMetricMapping.ts +++ b/spotlight-client/src/contentModels/createMetricMapping.ts @@ -51,6 +51,7 @@ import SupervisionSuccessRateDemographicsMetric from "./SupervisionSuccessRateDe import SupervisionSuccessRateMonthlyMetric from "./SupervisionSuccessRateMonthlyMetric"; import { ERROR_MESSAGES } from "../constants"; import { NOFILTER_KEY, TOTAL_KEY } from "../demographics"; +import { colors } from "../UiLibrary"; type MetricMappingFactoryOptions = { localityLabelMapping: TenantContent["localities"]; @@ -445,6 +446,7 @@ export default function createMetricMapping({ localityLabels: undefined, dataTransformer: prisonStayLengths, sourceFileName: "incarceration_lengths_by_demographics", + color: colors.dataVizNamed.get("teal"), }) ); break; diff --git a/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts b/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts index fde5deac..1e7f64bd 100644 --- a/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts +++ b/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts @@ -106,14 +106,14 @@ export function prisonReleaseTypes( return getCategoryTransposeFunction(prisonReleaseFields)(rawRecords); } -const prisonStayLengthFields = [ - { categoryLabel: "lessThanOne", fieldName: "years_0_1" }, - { categoryLabel: "oneTwo", fieldName: "years_1_2" }, - { categoryLabel: "twoThree", fieldName: "years_2_3" }, - { categoryLabel: "threeFive", fieldName: "years_3_5" }, - { categoryLabel: "fiveTen", fieldName: "years_5_10" }, - { categoryLabel: "tenTwenty", fieldName: "years_10_20" }, - { categoryLabel: "moreThanTwenty", fieldName: "years_20_plus" }, +export const prisonStayLengthFields = [ + { categoryLabel: "<1 year", fieldName: "years_0_1" }, + { categoryLabel: "1–2", fieldName: "years_1_2" }, + { categoryLabel: "2–3", fieldName: "years_2_3" }, + { categoryLabel: "3–5", fieldName: "years_3_5" }, + { categoryLabel: "5–10", fieldName: "years_5_10" }, + { categoryLabel: "10–20", fieldName: "years_10_20" }, + { categoryLabel: "20+", fieldName: "years_20_plus" }, ]; export function prisonStayLengths(