+ getYPosition: (index: number, label: string) => number
+}
+
+export default function GroupLabelsYAxis(props: GroupLabelsYAxisProps) {
+ return (
+
+ {props.wrappedLabels.map((label: any, index: number) => {
+ if (label.original === 'All' && props.useIntersectionalComparisonAlls) {
+ label.lines = getComparisonAllSubGroupLines(
+ props.fips,
+ props.comparisonAllSubGroup,
+ )
+ }
+ const yPosition = props.getYPosition(index, label.original)
+ return (
+
+ {label.lines.map((line: string, lineIndex: number) => {
+ return (
+
+ {line}
+
+ )
+ })}
+
+ )
+ })}
+
+ )
+}
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 */}
+
+
+
+ {/* X Axis Numbered Ticks */}
+ {props.xScale.ticks(numOfLabeledTicks).map((tick, index) => (
+
+
+
+ {formatTick(tick)}
+
+
+ ))}
+
+ >
+ )
+}
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 (