diff --git a/frontend/playwright-tests/depression.nightly.spec.ts b/frontend/playwright-tests/depression.nightly.spec.ts index 652ced9be5..461f53fed1 100644 --- a/frontend/playwright-tests/depression.nightly.spec.ts +++ b/frontend/playwright-tests/depression.nightly.spec.ts @@ -3,26 +3,55 @@ import { test } from '@playwright/test' test('Depression Flow', async ({ page }) => { await page.goto('/exploredata?mls=1.depression-3.00&group1=All') await page - .getByLabel( - 'Map showing Depression in the United States : including data from 50 states/territories' - ) - .getByRole('img') - await page.getByRole('button', { name: 'Expand state/territory rate' }).click(); + .locator('#rate-map') + .getByRole('heading', { name: 'Depression in the United' }) + .click() + await page + .locator('#rate-map') + .getByRole('heading', { name: 'Ages 18+' }) + .click() + await page + .getByRole('button', { name: 'Rates over time', exact: true }) + .click() + await page + .getByRole('heading', { name: 'Depression cases over time in' }) + .click() await page - .getByLabel( - 'Bar Chart showing Depression in the United States, by Race and Ethnicity' - ) - .getByRole('img') + .locator('#rates-over-time') + .getByRole('heading', { name: 'Ages 18+' }) .click() + await page.getByText('cases per 100k adults →').click() + await page.getByText('time →').click() + await page + .locator('#rates-over-time') + .getByText('Note. (NH) indicates ‘Non-') + .click() + await page.getByRole('button', { name: 'Rate chart' }).click() + await page + .locator('#rate-chart') + .getByRole('heading', { name: 'Depression in the United' }) + .click() + await page + .locator('#rate-chart') + .getByRole('heading', { name: 'Ages 18+' }) + .click() + await page.locator('#rate-chart').getByText('All').click() + await page.locator('#rate-chart').getByText('race and ethnicity').click() + await page.locator('#rate-chart').getByText('cases per 100k adults').click() await page.getByRole('button', { name: 'Unknown demographic map' }).click() + await page.getByText('No unknown values for race').click() + await page.getByRole('button', { name: 'Data table' }).click() + await page + .getByRole('heading', { name: 'Summary for depression in the' }) + .click() await page - .getByRole('heading', { - name: 'Share of total adult depression cases with unknown race and ethnicity in the United States', - }) + .getByRole('figure', { name: 'Summary for depression in the' }) + .locator('h4') .click() + await page.getByRole('columnheader', { name: 'Race and Ethnicity' }).click() await page - .getByText( - 'No unknown values for race and ethnicity reported in this dataset at the state/t' - ) + .getByRole('columnheader', { name: 'Cases of depression per 100k' }) .click() + await page.getByRole('columnheader', { name: 'Share of total adult' }).click() + await page.getByRole('columnheader', { name: 'Population share' }).click() }) diff --git a/frontend/playwright-tests/diabetes.ci.spec.ts b/frontend/playwright-tests/diabetes.ci.spec.ts index 4f3d1c95a8..dbb4139d2a 100644 --- a/frontend/playwright-tests/diabetes.ci.spec.ts +++ b/frontend/playwright-tests/diabetes.ci.spec.ts @@ -42,7 +42,7 @@ test('Diabetes County', async ({ page }) => { .locator('#rate-map') .getByRole('heading', { name: 'Ages 18+' }) .click() - await page.getByLabel('Legend for rate map').getByRole('img').click() + await page.getByLabel('Legend for rate map').click() await page.locator('li').filter({ hasText: 'Denver County' }).click() await page .locator('#rate-chart') diff --git a/frontend/playwright-tests/modals.ci.spec.ts b/frontend/playwright-tests/modals.ci.spec.ts index 14979f751e..ce56fa4c57 100644 --- a/frontend/playwright-tests/modals.ci.spec.ts +++ b/frontend/playwright-tests/modals.ci.spec.ts @@ -1,12 +1,12 @@ import { test, expect } from '@playwright/test' -test.setTimeout(120000); +test.setTimeout(120000) test('Topic Info Modal from Sidebar', async ({ page }) => { // Compare Topics Page Loads await page.goto( '/exploredata?mls=1.incarceration-3.poverty-5.13&mlp=comparevars&dt1=prison', - { waitUntil: 'commit' } + { waitUntil: 'commit' }, ) // Clicking topic info modal button launched modal @@ -27,24 +27,29 @@ test('Topic Info Modal from Sidebar', async ({ page }) => { test.describe('Topic Info Modal from Map Legend', () => { test('Topic Info Modal from Map Legend', async ({ page }) => { - await page.goto('/', { waitUntil: 'commit' }); - await page.locator('#landingPageCTA').click(); - const reportSection = await page.getByRole('heading', { name: 'Uninsurance in FL' }).locator('..'); - await reportSection.locator('text=Explore this report').click(); - await page.locator('#rate-map').getByRole('button', { name: 'Click for more info on uninsured people' }).click(); - }); -}); + await page.goto('/', { waitUntil: 'commit' }) + await page.locator('#landingPageCTA').click() + const reportSection = await page + .getByRole('heading', { name: 'Uninsurance in FL' }) + .locator('..') + await reportSection.locator('text=Explore this report').click() + await page + .locator('#rate-map') + .getByRole('button', { name: 'Click for more info on uninsured people' }) + .click() + }) +}) test('Multiple Maps 1 (Left Side)', async ({ page }) => { // Compare Topics Page With Multimap Open Loads await page.goto( '/exploredata?mls=1.incarceration-3.poverty-5.13&mlp=comparevars&dt1=prison&multiple-maps=true', - { waitUntil: 'commit' } + { waitUntil: 'commit' }, ) await expect( page.getByRole('heading', { name: 'Prison incarceration in Georgia across all race and ethnicity groups', - }) + }), ).toBeVisible() // CLOSE IT @@ -56,21 +61,21 @@ test('Multiple Maps 2 (Right Side)', async ({ page }) => { // Compare Topics Page Loads await page.goto( '/exploredata?mls=1.incarceration-3.poverty-5.13&mlp=comparevars&dt1=prison', - { waitUntil: 'commit' } + { waitUntil: 'commit' }, ) // Clicking right side multiple maps button launches POVERTY multimap modal await page .locator('#rate-map2') .getByLabel( - 'Launch multiple maps view with side-by-side maps of each race and ethnicity group' + 'Launch multiple maps view with side-by-side maps of each race and ethnicity group', ) .click() await expect(page).toHaveURL(/.*multiple-maps2=true/) await expect( page.getByRole('heading', { name: 'People below the poverty line in Georgia across all race and ethnicity groups', - }) + }), ).toBeVisible() // CLOSE IT diff --git a/frontend/playwright-tests/voter_participation.spec.ts b/frontend/playwright-tests/voter_participation.spec.ts index ecdbcee068..ef5e62cceb 100644 --- a/frontend/playwright-tests/voter_participation.spec.ts +++ b/frontend/playwright-tests/voter_participation.spec.ts @@ -23,7 +23,7 @@ test('Voter Participation Flow', async ({ page }) => { .locator('#rate-chart') .getByRole('heading', { name: 'U.S. citizens, Ages 18+' }) .click() - await page.getByLabel('Bar Chart showing Voter').getByRole('img').click() + await page.getByLabel('Bar Chart showing Voter').click() await page.getByRole('heading', { name: 'Share of all voter' }).click() await page.getByText('No unknown values for race').click() await page diff --git a/frontend/src/cards/SimpleBarChartCard.tsx b/frontend/src/cards/RateBarChartCard.tsx similarity index 92% rename from frontend/src/cards/SimpleBarChartCard.tsx rename to frontend/src/cards/RateBarChartCard.tsx index 04f4096711..8bee9ba514 100644 --- a/frontend/src/cards/SimpleBarChartCard.tsx +++ b/frontend/src/cards/RateBarChartCard.tsx @@ -1,5 +1,5 @@ -import { addComparisonAllsRowToIntersectionalData } from '../charts/simpleBarHelperFunctions' -import { SimpleHorizontalBarChart } from '../charts/SimpleHorizontalBarChart' +import { addComparisonAllsRowToIntersectionalData } from '../charts/rateBarChart/helpers' +import { RateBarChart } from '../charts/rateBarChart/Index' import { generateChartTitle, generateSubtitle } from '../charts/utils' import type { DataTypeConfig, MetricId } from '../data/config/MetricConfigTypes' import { isPctType } from '../data/config/MetricConfigUtils' @@ -35,8 +35,7 @@ import MissingDataAlert from './ui/MissingDataAlert' /* minimize layout shift */ const PRELOAD_HEIGHT = 668 -interface SimpleBarChartCardProps { - key?: string +interface RateBarChartCardProps { demographicType: DemographicType dataTypeConfig: DataTypeConfig fips: Fips @@ -46,16 +45,7 @@ interface SimpleBarChartCardProps { // This wrapper ensures the proper key is set to create a new instance when // required rather than relying on the card caller. -export default function SimpleBarChartCard(props: SimpleBarChartCardProps) { - return ( - - ) -} - -function SimpleBarChartCardWithKey(props: SimpleBarChartCardProps) { +export default function RateBarChartCard(props: RateBarChartCardProps) { const rateConfig = props.dataTypeConfig.metrics?.per100k ?? props.dataTypeConfig.metrics?.pct_rate ?? @@ -185,11 +175,10 @@ function SimpleBarChartCardWithKey(props: SimpleBarChartCardProps) { ) : ( <> - - row[props.metric.metricId])) * - (LABEL_SWAP_CUTOFF_PERCENT / 100) - - return ( -
- -
- ) -} \ No newline at end of file diff --git a/frontend/src/charts/rateBarChart/BarChartTooltip.tsx b/frontend/src/charts/rateBarChart/BarChartTooltip.tsx new file mode 100644 index 0000000000..64135f7464 --- /dev/null +++ b/frontend/src/charts/rateBarChart/BarChartTooltip.tsx @@ -0,0 +1,28 @@ +export interface BarChartTooltipData { + x: number + y: number + content: string +} + +interface BarChartTooltipProps { + data: BarChartTooltipData | null +} + +export default function BarChartTooltip({ data }: BarChartTooltipProps) { + if (!data) return null + const clickIsLeftHalfOfScreen = data.x < window.innerWidth / 2 + return ( +
+ {data.content} +
+ ) +} diff --git a/frontend/src/charts/rateBarChart/EndOfBarLabel.tsx b/frontend/src/charts/rateBarChart/EndOfBarLabel.tsx new file mode 100644 index 0000000000..b01c5459ce --- /dev/null +++ b/frontend/src/charts/rateBarChart/EndOfBarLabel.tsx @@ -0,0 +1,33 @@ +import type { ScaleBand } from 'd3' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import { formatValue } from './helpers' + +interface EndOfBarLabelProps { + metricConfig: MetricConfig + d: Record + shouldLabelBeInside: boolean + barWidth: number + yScale: ScaleBand + barLabelColor: string + isTinyAndUp: boolean +} + +export default function EndOfBarLabel(props: EndOfBarLabelProps) { + return ( + + ) +} diff --git a/frontend/src/charts/rateBarChart/GroupLabelsYAxis.tsx b/frontend/src/charts/rateBarChart/GroupLabelsYAxis.tsx new file mode 100644 index 0000000000..31d03a9b74 --- /dev/null +++ b/frontend/src/charts/rateBarChart/GroupLabelsYAxis.tsx @@ -0,0 +1,55 @@ +import type { ScaleBand } from 'd3' +import type { Fips } from '../../data/utils/Fips' +import { getComparisonAllSubGroupLines } from './helpers' + +type WrappedLabel = { + original: string + lines: string[] +} + +interface GroupLabelsYAxisProps { + fips: Fips + useIntersectionalComparisonAlls?: boolean + comparisonAllSubGroup?: string + wrappedLabels: WrappedLabel[] + yScale: ScaleBand + getYPosition: (index: number, label: string) => number +} + +export default function GroupLabelsYAxis(props: GroupLabelsYAxisProps) { + return ( + + ) +} diff --git a/frontend/src/charts/rateBarChart/Index.tsx b/frontend/src/charts/rateBarChart/Index.tsx new file mode 100644 index 0000000000..7aceb834fb --- /dev/null +++ b/frontend/src/charts/rateBarChart/Index.tsx @@ -0,0 +1,147 @@ +import { max, scaleBand, scaleLinear } from 'd3' +import { useMemo } from 'react' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import { + hasSkinnyGroupLabels, + type DemographicType, +} from '../../data/query/Breakdowns' +import { sortForVegaByIncome } from '../../data/sorting/IncomeSorterStrategy' +import type { HetRow } from '../../data/utils/DatasetTypes' +import type { Fips } from '../../data/utils/Fips' +import { useIsBreakpointAndUp } from '../../utils/hooks/useIsBreakpointAndUp' +import { useResponsiveWidth } from '../../utils/hooks/useResponsiveWidth' +import BarChartTooltip from './BarChartTooltip' +import { + BAR_HEIGHT, + BAR_PADDING, + EXTRA_SPACE_AFTER_ALL, + MARGIN, + MAX_LABEL_WIDTH_BIG, + MAX_LABEL_WIDTH_SMALL, + NORMAL_MARGIN_HEIGHT, + Y_AXIS_LABEL_HEIGHT, +} from './constants' +import RoundedBarsWithLabels from './RoundedBarsWithLabels' +import { useRateChartTooltip } from './useRateChartTooltip' +import VerticalGridlines from './VerticalGridlines' +import XAxis from './XAxis' +import YAxis from './YAxis' + +interface RateBarChartProps { + data: HetRow[] + metricConfig: MetricConfig + demographicType: DemographicType + fips: Fips + filename?: string + usePercentSuffix?: boolean + className?: string + useIntersectionalComparisonAlls?: boolean + comparisonAllSubGroup?: string +} + +export function RateBarChart(props: RateBarChartProps) { + const isTinyAndUp = useIsBreakpointAndUp('tiny') + const isSmAndUp = useIsBreakpointAndUp('sm') + + const [containerRef, width] = useResponsiveWidth() + + const { tooltipData, handleTooltip, closeTooltip, handleContainerTouch } = + useRateChartTooltip( + containerRef, + props.metricConfig, + props.demographicType, + isTinyAndUp, + ) + + const maxLabelWidth = hasSkinnyGroupLabels(props.demographicType) + ? MAX_LABEL_WIDTH_SMALL + : MAX_LABEL_WIDTH_BIG + MARGIN.left = maxLabelWidth + NORMAL_MARGIN_HEIGHT + if (isSmAndUp) MARGIN.left += Y_AXIS_LABEL_HEIGHT + + const processedData: HetRow[] = + props.demographicType === 'income' + ? sortForVegaByIncome(props.data) + : props.data + + const allIndex = processedData.findIndex( + (d) => d[props.demographicType] === 'All', + ) + const totalExtraSpace = allIndex !== -1 ? EXTRA_SPACE_AFTER_ALL : 0 + const height = processedData.length * (BAR_HEIGHT + 10) + totalExtraSpace + const innerWidth = width - MARGIN.left - MARGIN.right + const innerHeight = height - MARGIN.top - MARGIN.bottom + + // Scales + const xScale = useMemo(() => { + const maxValue = + max(processedData, (d) => d[props.metricConfig.metricId]) || 0 + return scaleLinear().domain([0, maxValue]).range([0, innerWidth]) + }, [processedData, innerWidth, props.metricConfig.metricId]) + + const yScale = useMemo(() => { + return scaleBand() + .domain(processedData.map((d) => d[props.demographicType])) + .range([0, innerHeight - totalExtraSpace]) // Adjust range to account for extra space + .padding(BAR_PADDING) + }, [processedData, innerHeight, totalExtraSpace]) + + const getYPosition = (index: number, demographicValue: string) => { + let position = yScale(demographicValue) || 0 + if (allIndex !== -1 && index > allIndex) { + position += EXTRA_SPACE_AFTER_ALL + } + return position + } + + return ( +
+ + {/* biome-ignore lint/a11y/noSvgWithoutTitle: we use aria-label instead, so screen reader has accessible text but browser tooltips don't interfere with custom tooltip */} + + + + + + + + +
+ ) +} diff --git a/frontend/src/charts/rateBarChart/RoundedBarsWithLabels.tsx b/frontend/src/charts/rateBarChart/RoundedBarsWithLabels.tsx new file mode 100644 index 0000000000..6739bd5737 --- /dev/null +++ b/frontend/src/charts/rateBarChart/RoundedBarsWithLabels.tsx @@ -0,0 +1,80 @@ +import { max, type ScaleBand, type ScaleLinear } from 'd3' +import { useMemo } from 'react' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import type { DemographicType } from '../../data/query/Breakdowns' +import type { HetRow } from '../../data/utils/DatasetTypes' +import { LABEL_SWAP_CUTOFF_PERCENT } from './constants' +import EndOfBarLabel from './EndOfBarLabel' +import { buildRoundedBarString } from './helpers' + +interface RoundedBarsWithLabelsProps { + processedData: HetRow[] + metricConfig: MetricConfig + demographicType: DemographicType + xScale: ScaleLinear + yScale: ScaleBand + getYPosition: (index: number, label: string) => number + isTinyAndUp: boolean + handleTooltip: any + closeTooltip: any +} + +export default function RoundedBarsWithLabels( + props: RoundedBarsWithLabelsProps, +) { + const barLabelBreakpoint = useMemo(() => { + const maxValue = + max(props.processedData, (d) => d[props.metricConfig.metricId]) || 0 + return maxValue * (LABEL_SWAP_CUTOFF_PERCENT / 100) + }, [props.processedData, props.metricConfig.metricId]) + + return props.processedData.map((d, index) => { + const barWidth = props.xScale(d[props.metricConfig.metricId]) || 0 + const shouldLabelBeInside = + d[props.metricConfig.metricId] > barLabelBreakpoint + const yPosition = props.getYPosition(index, d[props.demographicType]) + + const barLabelColor = + shouldLabelBeInside && d[props.demographicType] !== 'All' + ? 'fill-white' + : 'fill-current' + + const roundedBarString = buildRoundedBarString(barWidth, props.yScale) + + if (!roundedBarString) return <> + + const barAriaLabel = `${d[props.demographicType]}: ${d[props.metricConfig.metricId]} ${props.metricConfig.shortLabel}` + + return ( + props.handleTooltip(e, d, false)} + onMouseLeave={props.closeTooltip} + onTouchStart={(e) => { + props.handleTooltip(e, d, true) + }} + > + + + + ) + }) +} diff --git a/frontend/src/charts/rateBarChart/VerticalGridlines.tsx b/frontend/src/charts/rateBarChart/VerticalGridlines.tsx new file mode 100644 index 0000000000..f78b64bf31 --- /dev/null +++ b/frontend/src/charts/rateBarChart/VerticalGridlines.tsx @@ -0,0 +1,27 @@ +import type { ScaleLinear } from 'd3' +import { getNumTicks } from './helpers' + +interface VerticalGridlinesProps { + width: number + height: number + xScale: ScaleLinear +} + +export default function VerticalGridlines(props: VerticalGridlinesProps) { + const numTicks = getNumTicks(props.width) + + return ( + + {props.xScale.ticks(numTicks).map((tick, index) => ( + + ))} + + ) +} diff --git a/frontend/src/charts/rateBarChart/XAxis.tsx b/frontend/src/charts/rateBarChart/XAxis.tsx new file mode 100644 index 0000000000..bdfdb53d44 --- /dev/null +++ b/frontend/src/charts/rateBarChart/XAxis.tsx @@ -0,0 +1,57 @@ +import { format, type ScaleLinear } from 'd3' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import { useIsBreakpointAndUp } from '../../utils/hooks/useIsBreakpointAndUp' + +interface XAxisProps { + metricConfig: MetricConfig + xScale: ScaleLinear + width: number + height: number +} + +export default function XAxis(props: XAxisProps) { + const isTinyAndUp = useIsBreakpointAndUp('tiny') + const numOfLabeledTicks = isTinyAndUp ? 5 : 3 + + const formatTick = + props.metricConfig.type === 'per100k' + ? format('.2~s') // Uses SI-prefix with 2 significant digits + : format(',') // Uses thousands separator + + return ( + <> + {/* X Axis Metric Label */} + + {props.metricConfig.shortLabel} + + {/* X Axis */} + + + ) +} diff --git a/frontend/src/charts/rateBarChart/YAxis.tsx b/frontend/src/charts/rateBarChart/YAxis.tsx new file mode 100644 index 0000000000..ddd3b6690c --- /dev/null +++ b/frontend/src/charts/rateBarChart/YAxis.tsx @@ -0,0 +1,54 @@ +import type { ScaleBand } from 'd3' +import { useMemo } from 'react' +import { + type DemographicType, + DEMOGRAPHIC_DISPLAY_TYPES_LOWER_CASE, +} from '../../data/query/Breakdowns' +import type { HetRow } from '../../data/utils/DatasetTypes' +import type { Fips } from '../../data/utils/Fips' +import { MARGIN, Y_AXIS_LABEL_HEIGHT } from './constants' +import GroupLabelsYAxis from './GroupLabelsYAxis' +import { wrapLabel } from './helpers' + +interface YAxisProps { + demographicType: DemographicType + isSmAndUp: boolean + processedData: HetRow[] + maxLabelWidth: number + yScale: ScaleBand + getYPosition: (index: number, label: string) => number + fips: Fips +} +export default function YAxis(props: YAxisProps) { + const wrappedLabels = useMemo(() => { + return props.processedData.map((d) => ({ + original: d[props.demographicType], + lines: wrapLabel(d[props.demographicType], props.maxLabelWidth), + })) + }, [props.processedData, props.demographicType]) + + return ( + + {props.isSmAndUp && ( + + + {DEMOGRAPHIC_DISPLAY_TYPES_LOWER_CASE[props.demographicType]} + + + )} + + + + ) +} diff --git a/frontend/src/charts/rateBarChart/constants.ts b/frontend/src/charts/rateBarChart/constants.ts new file mode 100644 index 0000000000..cdc6d1cfbc --- /dev/null +++ b/frontend/src/charts/rateBarChart/constants.ts @@ -0,0 +1,26 @@ +const Y_AXIS_LABEL_HEIGHT = 20 +const BAR_PADDING = 0.2 +const LABEL_SWAP_CUTOFF_PERCENT = 66 +const MAX_LABEL_WIDTH_BIG = 100 +const MAX_LABEL_WIDTH_SMALL = 50 +const NORMAL_MARGIN_HEIGHT = 20 +const MARGIN = { + top: NORMAL_MARGIN_HEIGHT, + right: NORMAL_MARGIN_HEIGHT, + bottom: NORMAL_MARGIN_HEIGHT + 30, + left: NORMAL_MARGIN_HEIGHT, +} +const BAR_HEIGHT = 70 +const EXTRA_SPACE_AFTER_ALL = 10 + +export { + BAR_HEIGHT, + BAR_PADDING, + EXTRA_SPACE_AFTER_ALL, + LABEL_SWAP_CUTOFF_PERCENT, + MARGIN, + MAX_LABEL_WIDTH_BIG, + MAX_LABEL_WIDTH_SMALL, + NORMAL_MARGIN_HEIGHT, + Y_AXIS_LABEL_HEIGHT, +} diff --git a/frontend/src/charts/rateBarChart/helpers.test.ts b/frontend/src/charts/rateBarChart/helpers.test.ts new file mode 100644 index 0000000000..db811f57c7 --- /dev/null +++ b/frontend/src/charts/rateBarChart/helpers.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import { Fips } from '../../data/utils/Fips' +import { + formatValue, + getComparisonAllSubGroupLines, + wrapLabel, +} from './helpers' + +describe('wrapLabel', () => { + it('wraps text based on width', () => { + const result = wrapLabel('This is a long text that needs wrapping', 50) + expect(result).toEqual([ + 'This is', + 'a long', + 'text', + 'that', + 'needs', + 'wrapping', + ]) + }) + + it('handles single line text', () => { + const result = wrapLabel('Short text', 100) + expect(result).toEqual(['Short text']) + }) +}) + +describe('formatValue', () => { + it('formats per100k values', () => { + const config = { type: 'per100k' } as MetricConfig + expect(formatValue(1234.56, config, true)).toBe('1,235 per 100k') + }) + + it('formats per100k values on tiny mobile', () => { + const config = { type: 'per100k' } as MetricConfig + expect(formatValue(1234.56, config, false)).toBe('1,235') + }) + + it('formats percentage values', () => { + const config = { type: 'pct_rate' } as MetricConfig + expect(formatValue(45.67, config, true)).toBe('46%') + }) + + it('formats percentage values', () => { + const config = { type: 'pct_share' } as MetricConfig + expect(formatValue(45.67, config, true)).toBe('45.7%') + }) + + it('formats regular numbers', () => { + const config = { type: 'index' } as MetricConfig + expect(formatValue(1234567, config, true)).toBe('1,234,567') + }) +}) + +describe('getComparisonAllSubGroupLines', () => { + it('returns basic lines without comparison subgroup', () => { + const mockFips = new Fips('01') + const result = getComparisonAllSubGroupLines(mockFips) + expect(result).toEqual(['State', 'Average', 'All People']) + }) + + it('includes comparison subgroup when provided', () => { + const mockFips = new Fips('01001') + const result = getComparisonAllSubGroupLines(mockFips, 'Test Subgroup') + expect(result).toEqual(['County', 'Average', 'All People', 'Test Subgroup']) + }) +}) diff --git a/frontend/src/charts/rateBarChart/helpers.ts b/frontend/src/charts/rateBarChart/helpers.ts new file mode 100644 index 0000000000..3451fa8a9c --- /dev/null +++ b/frontend/src/charts/rateBarChart/helpers.ts @@ -0,0 +1,152 @@ +import type { ScaleBand } from 'd3' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import { isPctType, isRateType } from '../../data/config/MetricConfigUtils' +import type { DemographicType } from '../../data/query/Breakdowns' +import type { MetricQueryResponse } from '../../data/query/MetricQuery' +import { ALL } from '../../data/utils/Constants' +import type { HetRow } from '../../data/utils/DatasetTypes' +import type { Fips } from '../../data/utils/Fips' +import { useIsBreakpointAndUp } from '../../utils/hooks/useIsBreakpointAndUp' + +function wrapLabel(text: string, width: number): string[] { + const normalizedText = text.replace(/\s+/g, ' ').trim() + const words = normalizedText.split(' ') + const lines: string[] = [] + let currentLine = '' + + words.forEach((word) => { + const testLine = currentLine ? `${currentLine} ${word}` : word + if (testLine.length * 6 <= width) { + currentLine = testLine + } else { + lines.push(currentLine) + currentLine = word + } + }) + + if (currentLine) { + lines.push(currentLine) + } + + return lines +} + +function formatValue( + value: number, + metricConfig: MetricConfig, + isTinyAndUp: boolean, +): string { + let maxFractionDigits = 1 + if (isRateType(metricConfig.type)) { + if (value > 10) maxFractionDigits = 0 + else if (value > 1) maxFractionDigits = 1 + else if (value > 0.1) maxFractionDigits = 2 + } + + if (metricConfig.type === 'per100k') { + const roundedVal = Math.round(value).toLocaleString('en-US', { + maximumFractionDigits: maxFractionDigits, + }) + return isTinyAndUp ? roundedVal + ' per 100k' : roundedVal + } + + if (isPctType(metricConfig.type)) + return ( + value.toLocaleString('en-US', { + maximumFractionDigits: maxFractionDigits, + }) + '%' + ) + + return value.toLocaleString('en-US') +} + +function getNumTicks(width: number): number { + const isSmMd = useIsBreakpointAndUp('smMd') + const isCompareMode = window.location.href.includes('compare') + let numTicks = Math.floor(width / 40) + if (isCompareMode || !isSmMd) { + numTicks = Math.max(Math.floor(numTicks / 1.5), 5) + } + return numTicks +} + +function getComparisonAllSubGroupLines( + fips: Fips, + comparisonAllSubGroup?: string, +) { + const lines: string[] = [ + fips.getUppercaseFipsTypeDisplayName() || '', + 'Average', + 'All People', + ] + + if (comparisonAllSubGroup) { + lines.push(comparisonAllSubGroup) + } + return lines +} + +function buildRoundedBarString(barWidth: number, yScale: ScaleBand) { + const CORNER_RADIUS = 4 + + const safeBarWidth = Math.max(0, barWidth) + const safeCornerRadius = Math.min(CORNER_RADIUS, safeBarWidth / 2) + const safeBandwidth = yScale.bandwidth() || 0 + if (safeBarWidth <= 0 || safeBandwidth <= 0) return '' + + return ` + M 0,0 + h ${safeBarWidth - safeCornerRadius} + q ${safeCornerRadius},0 ${safeCornerRadius},${safeCornerRadius} + v ${safeBandwidth - 2 * safeCornerRadius} + q 0,${safeCornerRadius} -${safeCornerRadius},${safeCornerRadius} + h -${safeBarWidth - safeCornerRadius} + Z + ` +} + +function addComparisonAllsRowToIntersectionalData( + data: HetRow[], + demographicType: DemographicType, + rateConfig: MetricConfig, + rateComparisonConfig: MetricConfig, + rateQueryResponseRateAlls: MetricQueryResponse, +) { + // rename intersectional 'All' group + const adjustedData = data.map((row) => { + const renameRow = { ...row } + if (row[demographicType] === ALL) { + renameRow[demographicType] = rateComparisonConfig?.shortLabel + } + return renameRow + }) + + // add the comparison ALLs row to the intersectional data + const originalAllsRow = rateQueryResponseRateAlls?.data?.[0] + + if (!originalAllsRow) { + return adjustedData + } + + const { fips, fips_name } = originalAllsRow + + const allsRow = { + fips, + fips_name, + [demographicType]: ALL, + [rateConfig.metricId]: + originalAllsRow[rateConfig?.rateComparisonMetricForAlls?.metricId ?? ''], + } + adjustedData.unshift(allsRow) + + return adjustedData +} + +export { + addComparisonAllsRowToIntersectionalData, + buildRoundedBarString, + formatValue, + getComparisonAllSubGroupLines, + getNumTicks, + wrapLabel, +} diff --git a/frontend/src/charts/rateBarChart/useRateChartTooltip.tsx b/frontend/src/charts/rateBarChart/useRateChartTooltip.tsx new file mode 100644 index 0000000000..fcc30d447a --- /dev/null +++ b/frontend/src/charts/rateBarChart/useRateChartTooltip.tsx @@ -0,0 +1,75 @@ +import { useCallback, useState, type RefObject } from 'react' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import type { HetRow } from '../../data/utils/DatasetTypes' +import type { BarChartTooltipData } from './BarChartTooltip' +import { formatValue } from './helpers' + +export function useRateChartTooltip( + containerRef: RefObject, + metricConfig: MetricConfig, + demographicType: string, + isTinyAndUp: boolean, +) { + const [tooltipData, setTooltipData] = useState( + null, + ) + + const handleTooltip = useCallback( + ( + event: React.MouseEvent | React.TouchEvent, + d: HetRow, + isTouchEvent: boolean, + ) => { + const svgRect = containerRef.current?.getBoundingClientRect() + if (!svgRect) return + + let clientX: number + let clientY: number + + if (isTouchEvent) { + const touchEvent = event as React.TouchEvent + const touch = touchEvent.touches[0] + clientX = touch.clientX + clientY = touch.clientY + } else { + const mouseEvent = event as React.MouseEvent + clientX = mouseEvent.clientX + clientY = mouseEvent.clientY + } + + const tooltipContent = `${d[demographicType]}: ${formatValue( + d[metricConfig.metricId], + metricConfig, + isTinyAndUp, + )}` + + setTooltipData({ + x: clientX - svgRect.left, + y: clientY - svgRect.top, + content: tooltipContent, + }) + }, + [containerRef, demographicType, metricConfig], + ) + + const closeTooltip = useCallback(() => { + setTooltipData(null) + }, []) + + const handleContainerTouch = useCallback( + (event: React.TouchEvent) => { + const target = event.target as SVGElement + if (target.tagName !== 'path') { + closeTooltip() + } + }, + [closeTooltip], + ) + + return { + tooltipData, + handleTooltip, + closeTooltip, + handleContainerTouch, + } +} diff --git a/frontend/src/charts/simpleBarHelperFunctions.ts b/frontend/src/charts/simpleBarHelperFunctions.ts deleted file mode 100644 index 40ae0f51ab..0000000000 --- a/frontend/src/charts/simpleBarHelperFunctions.ts +++ /dev/null @@ -1,338 +0,0 @@ -import type { MetricConfig, MetricId } from '../data/config/MetricConfigTypes' -import type { - DemographicType, - DemographicTypeDisplayName, -} from '../data/query/Breakdowns' -import type { MetricQueryResponse } from '../data/query/MetricQuery' -import type { HetRow } from '../data/utils/DatasetTypes' -import type { Fips } from '../data/utils/Fips' -import { het, ThemeZIndexValues } from '../styles/DesignTokens' -import { createBarLabel } from './mapHelperFunctions' -import { - PADDING_FOR_ACTIONS_MENU, - oneLineLabel, - CORNER_RADIUS, - MULTILINE_LABEL, - AXIS_LABEL_Y_DELTA, - LABEL_HEIGHT, -} from './utils' - -const MEASURE_GROUP_COLOR = het.altGreen -const MEASURE_ALL_COLOR = het.timeYellow -const BAR_HEIGHT = 60 -const BAR_PADDING = 0.2 -export const specialAllGroup = 'All' -export const DATASET = 'DATASET' - -export function getSpec( - altText: string, - data: HetRow[], - width: number, - demographicType: DemographicType, - demographicTypeDisplayName: DemographicTypeDisplayName, - measure: MetricId, - measureDisplayName: string, - tooltipMetricDisplayColumnName: string, - showLegend: boolean, - barLabelBreakpoint: number, - usePercentSuffix: boolean, - fips: Fips, - useIntersectionalComparisonAlls?: boolean, - comparisonAllSubGroup?: string, -): any { - function getMultilineAllOverride(fips: Fips): string { - // only swap the intersectional ALL for the ALL AVERAGE label if it's an intersectional topic - if (useIntersectionalComparisonAlls) { - return `['${fips.getUppercaseFipsTypeDisplayName()} Average', 'All People', '${comparisonAllSubGroup || ''}']` - } - return MULTILINE_LABEL - } - const chartIsSmall = width < 400 - - const createAxisTitle = () => { - if (chartIsSmall) { - return measureDisplayName.split(' ') - } else return measureDisplayName - } - - // create bar label as array or string - const barLabel = createBarLabel( - chartIsSmall, - measure, - tooltipMetricDisplayColumnName, - usePercentSuffix, - ) - - const legends = showLegend - ? [ - { - fill: 'variables', - orient: 'top', - padding: 4, - }, - ] - : [] - - const onlyZeros = data.every((row: HetRow) => { - return !row[measure as keyof HetRow] - }) - - return { - $schema: 'https://vega.github.io/schema/vega/v5.json', - description: altText, - background: het.white, - autosize: { resize: true, type: 'fit-x' }, - width: width - PADDING_FOR_ACTIONS_MENU, - style: 'cell', - data: [ - { - name: DATASET, - values: data, - }, - ], - signals: [ - { - name: 'y_step', - value: BAR_HEIGHT, - }, - { - name: 'height', - update: "bandspace(domain('y').length, 0.1, 0.05) * y_step + 10", - }, - ], - marks: [ - { - // chart bars - name: 'measure_bars', - type: 'rect', - style: ['bar'], - description: data.length + ' items', - from: { data: DATASET }, - encode: { - enter: { - tooltip: { - signal: `${oneLineLabel( - demographicType, - )} + ', ${measureDisplayName}: ' + datum.${tooltipMetricDisplayColumnName}`, - }, - }, - update: { - cornerRadiusTopRight: { - value: CORNER_RADIUS, - }, - cornerRadiusBottomRight: { - value: CORNER_RADIUS, - }, - fill: { - signal: `datum.${demographicType} === '${specialAllGroup}' ? '${MEASURE_ALL_COLOR}' : '${MEASURE_GROUP_COLOR}'`, - }, - x: { scale: 'x', field: measure }, - x2: { scale: 'x', value: 0 }, - y: { - scale: 'y', - field: demographicType, - // band: 1, - offset: { - signal: `datum.${demographicType} === '${specialAllGroup}' ? 0 : 10`, - }, - }, - height: { scale: 'y', band: 1 }, - }, - }, - }, - { - // ALT TEXT: invisible, verbose labels - name: 'measure_a11y_text_labels', - type: 'text', - from: { data: DATASET }, - encode: { - update: { - y: { scale: 'y', field: demographicType, band: 0.8 }, - opacity: { - signal: '0', - }, - fontSize: { value: 0 }, - text: { - signal: `${oneLineLabel( - demographicType, - )} + ': ' + datum.${tooltipMetricDisplayColumnName} + ' ${measureDisplayName}'`, - }, - }, - }, - }, - // Labels on Bars - { - name: 'measure_text_labels', - type: 'text', - style: ['text'], - from: { data: DATASET }, - aria: false, // this data already accessible in alt_text_labels above - encode: { - enter: { - tooltip: { - signal: `${oneLineLabel( - demographicType, - )} + ', ${measureDisplayName}: ' + datum.${tooltipMetricDisplayColumnName}`, - }, - }, - update: { - fontSize: { value: width > 250 ? 11 : 7.5 }, - align: { - signal: `if(datum.${measure} > ${barLabelBreakpoint}, "right", "left")`, - }, - baseline: { value: 'middle' }, - dx: { - signal: `if(datum.${measure} > ${barLabelBreakpoint}, -5,${ - width > 250 ? '5' : '1' - })`, - }, - dy: { - signal: chartIsSmall ? -15 : 0, - }, - fill: { - signal: `if(datum.${measure} > ${barLabelBreakpoint} && datum.${demographicType} !== '${specialAllGroup}', '${het.white}', '${het.black}')`, - }, - x: { scale: 'x', field: measure }, - y: { - scale: 'y', - field: demographicType, - band: 0.8, - offset: { - signal: `datum.${demographicType} === '${specialAllGroup}' ? 0 : 10`, - }, - }, - limit: { signal: 'width / 3' }, - text: { - signal: barLabel, - }, - }, - }, - }, - ], - scales: [ - { - name: 'x', - type: 'linear', - // if all rows contain 0 or null, set full x range to 100% - domainMax: onlyZeros ? 100 : undefined, - domain: { - data: DATASET, - field: measure, - }, - range: [0, { signal: 'width' }], - nice: true, - zero: true, - }, - { - name: 'y', - type: 'band', - domain: { - data: DATASET, - field: demographicType, - }, - range: { step: { signal: 'y_step' } }, - paddingOuter: 0.1, - paddingInner: BAR_PADDING, - }, - - { - name: 'variables', - type: 'ordinal', - domain: [measureDisplayName], - range: [MEASURE_GROUP_COLOR, MEASURE_ALL_COLOR], - }, - ], - axes: [ - { - scale: 'x', - orient: 'bottom', - gridScale: 'y', - grid: true, - tickCount: { signal: 'ceil(width/40)' }, - domain: false, - labels: false, - aria: false, - maxExtent: 0, - minExtent: 0, - ticks: false, - zindex: ThemeZIndexValues.middle, - }, - { - scale: 'x', - orient: 'bottom', - grid: false, - title: createAxisTitle(), - titleX: chartIsSmall ? 0 : undefined, - titleAnchor: chartIsSmall ? 'end' : 'null', - titleAlign: chartIsSmall ? 'left' : 'center', - labelFlush: true, - labelOverlap: true, - tickCount: { signal: 'ceil(width/40)' }, - zindex: ThemeZIndexValues.middle, - titleLimit: { signal: 'width - 10 ' }, - }, - { - scale: 'y', - orient: 'left', - grid: false, - title: demographicTypeDisplayName, - zindex: ThemeZIndexValues.middle, - encode: { - labels: { - update: { - // text: { signal: MULTILINE_LABEL }, - text: { - signal: `datum.value === '${specialAllGroup}' ? ${getMultilineAllOverride(fips)} : ${MULTILINE_LABEL}`, - }, - baseline: { value: 'bottom' }, - // Limit at which line is truncated with an ellipsis - limit: { value: 100 }, - dy: { - signal: `datum.demographicType !== '${specialAllGroup}' ? 5 : ${AXIS_LABEL_Y_DELTA}`, // Adjust based on AXIS_LABEL_Y_DELTA - }, - lineHeight: { signal: LABEL_HEIGHT }, - }, - }, - }, - }, - ], - legends, - } -} - -export function addComparisonAllsRowToIntersectionalData( - data: HetRow[], - demographicType: DemographicType, - rateConfig: MetricConfig, - rateComparisonConfig: MetricConfig, - rateQueryResponseRateAlls: MetricQueryResponse, -) { - // rename intersectional 'All' group - const adjustedData = data.map((row) => { - const renameRow = { ...row } - if (row[demographicType] === specialAllGroup) { - renameRow[demographicType] = rateComparisonConfig?.shortLabel - } - return renameRow - }) - - // add the comparison ALLs row to the intersectional data - const originalAllsRow = rateQueryResponseRateAlls?.data?.[0] - - if (!originalAllsRow) { - return adjustedData - } - - const { fips, fips_name } = originalAllsRow - - const allsRow = { - fips, - fips_name, - [demographicType]: specialAllGroup, - [rateConfig.metricId]: - originalAllsRow[rateConfig?.rateComparisonMetricForAlls?.metricId ?? ''], - } - adjustedData.unshift(allsRow) - - return adjustedData -} diff --git a/frontend/src/data/config/MetricConfigUtils.ts b/frontend/src/data/config/MetricConfigUtils.ts index 065556a7a8..dfc1522eea 100644 --- a/frontend/src/data/config/MetricConfigUtils.ts +++ b/frontend/src/data/config/MetricConfigUtils.ts @@ -37,6 +37,10 @@ export function isPctType(metricType: MetricType) { return ['pct_share', 'pct_relative_inequity', 'pct_rate'].includes(metricType) } +export function isRateType(metricType: MetricType) { + return ['pct_rate', 'per100k', 'index'].includes(metricType) +} + /** * @param metricType The type of the metric to format. * @param value The value to format. diff --git a/frontend/src/data/query/Breakdowns.ts b/frontend/src/data/query/Breakdowns.ts index 45a1eee9f8..5bc2968c8a 100644 --- a/frontend/src/data/query/Breakdowns.ts +++ b/frontend/src/data/query/Breakdowns.ts @@ -363,3 +363,14 @@ export class Breakdowns { return joinCols.sort() } } + +const smallerDemographicLabelTypes: DemographicType[] = [ + 'sex', + 'age', + 'insurance_status', +] + +// if all the groups in the breakdown have few letters, useful when we need to estimate the width of the y-axis labels +export function hasSkinnyGroupLabels(demographicType: DemographicType) { + return smallerDemographicLabelTypes.includes(demographicType) +} diff --git a/frontend/src/reports/CompareReport.tsx b/frontend/src/reports/CompareReport.tsx index 98b2645a64..5f498525c6 100644 --- a/frontend/src/reports/CompareReport.tsx +++ b/frontend/src/reports/CompareReport.tsx @@ -5,9 +5,9 @@ import AgeAdjustedTableCard from '../cards/AgeAdjustedTableCard' import CompareBubbleChartCard from '../cards/CompareBubbleChartCard' import DisparityBarChartCard from '../cards/DisparityBarChartCard' import MapCard from '../cards/MapCard' +import RateBarChartCard from '../cards/RateBarChartCard' import RateTrendsChartCard from '../cards/RateTrendsChartCard' import ShareTrendsChartCard from '../cards/ShareTrendsChartCard' -import SimpleBarChartCard from '../cards/SimpleBarChartCard' import TableCard from '../cards/TableCard' import UnknownsMapCard from '../cards/UnknownsMapCard' import type { DropdownVarId } from '../data/config/DropDownIds' @@ -296,7 +296,7 @@ export default function CompareReport(props: CompareReportProps) { fips: Fips, unusedUpdateFips: (fips: Fips) => void, ) => ( - = ({ className, }) => { return ( - { return (
- - -