diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b46d10d5ec..83b906ceb6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9462,4 +9462,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/playwright-tests/navigation.ci.spec.ts b/frontend/playwright-tests/navigation.ci.spec.ts index 894cecd1f1..7e28bfae32 100644 --- a/frontend/playwright-tests/navigation.ci.spec.ts +++ b/frontend/playwright-tests/navigation.ci.spec.ts @@ -51,7 +51,7 @@ test('Clicking a county on state map loads county report; back button returns to await page.locator('path:nth-child(122)').click() // Confirm correct madlib setting includes FIPS for county - await expect(page).toHaveURL(/.*mls=1.incarceration-3.13241/) + await expect(page).toHaveURL(/.*mls=1.incarceration-3.13177/) // TODO: This is broken, re-enable once fixed. Possibly related to #2712 // back button should take you back to state report diff --git a/frontend/playwright-tests/suicide.nightly.spec.ts b/frontend/playwright-tests/suicide.nightly.spec.ts index 2a3d5e5a09..e50eeb4e4e 100644 --- a/frontend/playwright-tests/suicide.nightly.spec.ts +++ b/frontend/playwright-tests/suicide.nightly.spec.ts @@ -46,7 +46,7 @@ test('Suicide California Showing Counties', async ({ page }) => { .getByRole('button', { name: 'Collapse county rate extremes' }) .click() // click a county - await page.locator('g:nth-child(3) > path:nth-child(3)').first().click() + await page.locator('path:nth-child(3)').first().click() }) test('Suicide Los Angeles County', async ({ page }) => { await page.goto( diff --git a/frontend/playwright-tests/vaccination.ci.spec.ts b/frontend/playwright-tests/vaccination.ci.spec.ts index b101bc459a..723b04599c 100644 --- a/frontend/playwright-tests/vaccination.ci.spec.ts +++ b/frontend/playwright-tests/vaccination.ci.spec.ts @@ -70,6 +70,8 @@ test('County Vaccination Quick Test', async ({ page }) => { exact: true, }) .click() - await page.getByLabel('Map showing COVID-19').locator('path').nth(1).click() + await page + .getByRole('img', { name: 'Map showing COVID-19' }) + .getByLabel('Map showing COVID-19') await page.getByText('This county has a social').click() }) diff --git a/frontend/src/cards/MapCard.tsx b/frontend/src/cards/MapCard.tsx index 15fa161b16..ba1507db00 100644 --- a/frontend/src/cards/MapCard.tsx +++ b/frontend/src/cards/MapCard.tsx @@ -1,8 +1,8 @@ import { GridView } from '@mui/icons-material' import { useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' -import ChoroplethMap from '../charts/ChoroplethMap' import { Legend } from '../charts/Legend' +import ChoroplethMap from '../charts/choroplethMap/index' import { type CountColsMap, RATE_MAP_SCALE } from '../charts/mapGlobals' import { getHighestLowestGroupsByFips } from '../charts/mapHelperFunctions' import { generateChartTitle, generateSubtitle } from '../charts/utils' @@ -149,7 +149,7 @@ function MapCardWithKey(props: MapCardProps) { const fipsTypeDisplayName = props.fips.getFipsTypeDisplayName() - const [scale, setScale] = useState<{ domain: number[]; range: number[] }>({ + const [scale, setScale] = useState<{ domain: number[]; range: string[] }>({ domain: [], range: [], }) @@ -259,7 +259,7 @@ function MapCardWithKey(props: MapCardProps) { if (extremesMode) subtitle += ` (only ${pluralChildFips} with rate extremes)` const filename = `${title} ${subtitle ? `for ${subtitle}` : ''}` - function handleScaleChange(domain: number[], range: number[]) { + function handleScaleChange(domain: number[], range: string[]) { // Update the scale state when the domain or range changes setScale({ domain, range }) } @@ -553,8 +553,8 @@ function MapCardWithKey(props: MapCardProps) { } signalListeners={signalListeners} mapConfig={mapConfig} - scaleConfig={scale} isPhrmaAdherence={isPhrmaAdherence} + scaleConfig={scale} /> diff --git a/frontend/src/cards/UnknownsMapCard.tsx b/frontend/src/cards/UnknownsMapCard.tsx index 771e37daee..ad5ee28070 100644 --- a/frontend/src/cards/UnknownsMapCard.tsx +++ b/frontend/src/cards/UnknownsMapCard.tsx @@ -2,9 +2,12 @@ import { useLocation } from 'react-router-dom' import type { Topology } from 'topojson-specification' import ChoroplethMap from '../charts/choroplethMap/index' import type { DataPoint } from '../charts/choroplethMap/types' -import { MAP_SCHEMES, type MapConfig } from '../charts/choroplethMap/types' +import { MAP_SCHEMES } from '../charts/mapGlobals' import { generateChartTitle, generateSubtitle } from '../charts/utils' -import type { DataTypeConfig } from '../data/config/MetricConfigTypes' +import type { + DataTypeConfig, + MapConfig, +} from '../data/config/MetricConfigTypes' import { Breakdowns, DEMOGRAPHIC_DISPLAY_TYPES_LOWER_CASE, @@ -232,7 +235,6 @@ function UnknownsMapCardWithKey(props: UnknownsMapCardProps) { metric={metricConfig} showCounties={!props.fips.isUsa()} signalListeners={signalListeners} - updateFipsCallback={props.updateFipsCallback} /> {props.fips.isUsa() && unknowns.length > 0 && ( ({ + const [scale, setScale] = useState<{ domain: number[]; range: string[] }>({ domain: [], range: [], }) - function handleScaleChange(domain: number[], range: number[]) { + function handleScaleChange(domain: number[], range: string[]) { // Update the scale state when the domain or range changes setScale({ domain, range }) } diff --git a/frontend/src/cards/ui/TerritoryCircles.tsx b/frontend/src/cards/ui/TerritoryCircles.tsx index 5e156f9be2..afd0e70870 100644 --- a/frontend/src/cards/ui/TerritoryCircles.tsx +++ b/frontend/src/cards/ui/TerritoryCircles.tsx @@ -30,7 +30,7 @@ interface TerritoryCirclesProps { demographicType: DemographicType activeDemographicGroup: DemographicGroup fullData?: HetRow[] - scaleConfig?: { domain: number[]; range: number[] } + scaleConfig?: { domain: number[]; range: string[] } isMulti?: boolean isPhrmaAdherence?: boolean } diff --git a/frontend/src/charts/ChoroplethMap.tsx b/frontend/src/charts/ChoroplethMap.tsx index c04803164c..90a015431d 100644 --- a/frontend/src/charts/ChoroplethMap.tsx +++ b/frontend/src/charts/ChoroplethMap.tsx @@ -105,7 +105,7 @@ interface ChoroplethMapProps { mapConfig: MapConfig isSummaryLegend?: boolean isMulti?: boolean - scaleConfig?: { domain: number[]; range: number[] } + scaleConfig?: { domain: number[]; range: string[] } highestLowestGroupsByFips?: Record activeDemographicGroup: DemographicGroup isPhrmaAdherence?: boolean @@ -308,7 +308,7 @@ export default function ChoroplethMap(props: ChoroplethMapProps) { ? UNKNOWNS_MAP_SCALE : RATE_MAP_SCALE, /* fieldRange? */ props.fieldRange, - /* scaleColorScheme? */ props.mapConfig.scheme, + /* scaleColorScheme? */ props.mapConfig.scheme as string, /* isTerritoryCircle? */ props.fips.isTerritory(), /* reverse? */ !props.mapConfig.higherIsBetter && !props.isUnknownsMap, ) diff --git a/frontend/src/charts/Legend.tsx b/frontend/src/charts/Legend.tsx index 74b1313fb4..716c051534 100644 --- a/frontend/src/charts/Legend.tsx +++ b/frontend/src/charts/Legend.tsx @@ -70,7 +70,7 @@ interface LegendProps { mapConfig: MapConfig columns: number stackingDirection: StackingDirection - handleScaleChange?: (domain: number[], range: number[]) => void + handleScaleChange?: (domain: number[], range: string[]) => void isMulti?: boolean isPhrmaAdherence?: boolean } @@ -162,12 +162,17 @@ export function Legend(props: LegendProps) { // MAKE AND ADD UNKNOWN LEGEND ITEM IF NEEDED if (hasMissingData) legendList.push(UNKNOWN_LEGEND_SPEC) + const mapScheme = + typeof props.mapConfig.scheme === 'string' + ? props.mapConfig.scheme + : 'darkgreen' + const legendColorScaleSpec = props.isPhrmaAdherence ? PHRMA_COLOR_SCALE_SPEC : setupStandardColorScaleSpec( props.scaleType, props.metric.metricId, - props.mapConfig.scheme, + mapScheme, legendColorCount, props.isSummaryLegend, /* reverse?: boolean */ !props.mapConfig.higherIsBetter, diff --git a/frontend/src/charts/choroplethMap/colorSchemes.ts b/frontend/src/charts/choroplethMap/colorSchemes.ts new file mode 100644 index 0000000000..d3e699a1b3 --- /dev/null +++ b/frontend/src/charts/choroplethMap/colorSchemes.ts @@ -0,0 +1,126 @@ +import * as d3 from 'd3' +import type { ColorScheme } from 'vega' +import { het } from '../../styles/DesignTokens' +import { getLegendDataBounds } from '../mapHelperFunctions' +import type { CreateColorScaleProps, GetFillColorProps } from './types' + +const { altGrey: ALT_GREY, white: WHITE } = het + +export const interpolateDarkGreen = d3.piecewise(d3.interpolateRgb.gamma(2.2), [ + het.mapDarkest, + het.mapDarker, + het.mapDark, + het.mapLight, + het.mapLighter, + het.mapLightest, + het.mapLightZero, +]) + +export const interpolatePlasma = d3.piecewise(d3.interpolateRgb.gamma(2.2), [ + het.mapWomenDarkZero, + het.mapWomenDarkest, + het.mapWomenDarker, + het.mapWomenDark, + het.mapWomenLight, + het.mapWomenLighter, + het.mapWomenLightest, + het.mapWomenLightZero, +]) + +export const interpolatedarkRed = d3.piecewise(d3.interpolateRgb.gamma(2.2), [ + het.mapYouthDarkZero, + het.mapYouthDarkest, + het.mapYouthDarker, + het.mapYouthDark, + het.mapYouthMid, + het.mapYouthLight, + het.mapYouthLighter, + het.mapYouthLightest, + het.mapYouthLightZero, +]) + +export const D3_MAP_SCHEMES: Partial< + Record string> +> = { + darkgreen: interpolateDarkGreen, + plasma: interpolatePlasma, + inferno: d3.interpolateInferno, + viridis: d3.interpolateViridis, + greenblue: d3.interpolateGnBu, + darkred: interpolatedarkRed, +} + +export const createColorScale = (props: CreateColorScaleProps) => { + let interpolatorFn + + if (props.scaleConfig?.range) { + let colorArray = props.scaleConfig.range + if (props.reverse) { + colorArray = [...colorArray].reverse() + } + interpolatorFn = d3.piecewise(d3.interpolateRgb.gamma(2.2), colorArray) + } else { + const resolvedScheme = + typeof props.colorScheme === 'string' + ? D3_MAP_SCHEMES[props.colorScheme] || d3.interpolateBlues + : props.colorScheme + + interpolatorFn = props.reverse + ? (t: number) => resolvedScheme(1 - t) + : resolvedScheme + } + + const [legendLowerBound, legendUpperBound] = getLegendDataBounds( + props.dataWithHighestLowest, + props.metricId, + ) + + const [min, max] = props.fieldRange + ? [props.fieldRange.min, props.fieldRange.max] + : [legendLowerBound, legendUpperBound] + + if (min === undefined || max === undefined || isNaN(min) || isNaN(max)) { + return d3.scaleSequential(interpolatorFn).domain([0, 1]) + } + + if (props.isPhrma) { + const thresholds = props.scaleConfig?.domain || [] + const colors = props.scaleConfig?.range || [] + return d3.scaleThreshold().domain(thresholds).range(colors) + } + + if (props.isUnknown) { + const darkerInterpolator = (t: number) => { + const color = d3.color(interpolatorFn(t)) + return color ? color.darker(0.1) : null + } + return d3 + .scaleSequentialSymlog() + .domain([min, max]) + .interpolator(darkerInterpolator) + } + + const domain = props.scaleConfig?.domain || [] + const range = props.scaleConfig?.range || [] + return d3.scaleQuantile().domain(domain).range(range) +} + +export const getFillColor = (props: GetFillColorProps): string => { + const { d, dataMap, colorScale, extremesMode, zeroColor, countyColor } = props + + const value = dataMap.get(d.id as string)?.value as number + + if (props.fips?.isCounty()) { + return countyColor + } + + if (value === 0) { + return zeroColor + } + + if (value !== undefined) { + return colorScale(value) + } + + return extremesMode ? WHITE : ALT_GREY +} diff --git a/frontend/src/charts/choroplethMap/index.tsx b/frontend/src/charts/choroplethMap/index.tsx index db9ad22896..9370e0f35c 100644 --- a/frontend/src/charts/choroplethMap/index.tsx +++ b/frontend/src/charts/choroplethMap/index.tsx @@ -1,119 +1,182 @@ -import { useEffect, useRef } from 'react' +import * as d3 from 'd3' +import { useEffect, useMemo, useRef } from 'react' +import { CAWP_METRICS } from '../../data/providers/CawpProvider' +import { PHRMA_METRICS } from '../../data/providers/PhrmaProvider' import { useIsBreakpointAndUp } from '../../utils/hooks/useIsBreakpointAndUp' import { useResponsiveWidth } from '../../utils/hooks/useResponsiveWidth' import { INVISIBLE_PRELOAD_WIDTH } from '../mapGlobals' +import { embedHighestLowestGroups } from '../mapHelperFunctions' import { HEIGHT_WIDTH_RATIO } from '../utils' +import { createColorScale } from './colorSchemes' import { - createColorScale, createFeatures, createProjection, + processPhrmaData, } from './mapHelpers' import { renderMap } from './renderMap' -import { - createTooltipContainer, - createTooltipLabel, - getTooltipPairs, -} from './tooltipUtils' -import type { ChoroplethMapProps } from './types' - -const ChoroplethMap = (props: ChoroplethMapProps) => { - const { - data, - metric, - isUnknownsMap, - activeDemographicGroup, - demographicType, - fips, - geoData, - showCounties, - overrideShapeWithCircle, - updateFipsCallback, - mapConfig, - } = props +import { createTooltipContainer } from './tooltipUtils' +import type { ChoroplethMapProps, DataPoint } from './types' + +const ChoroplethMap = ({ + data, + metric, + highestLowestGroupsByFips, + overrideShapeWithCircle, + showCounties, + fips, + geoData, + activeDemographicGroup, + demographicType, + countColsMap, + isUnknownsMap = false, + isMulti = false, + extremesMode, + mapConfig, + signalListeners, + scaleConfig, + filename, +}: ChoroplethMapProps) => { + const isMobile = !useIsBreakpointAndUp('md') const [ref, width] = useResponsiveWidth() const svgRef = useRef(null) - const isMobile = !useIsBreakpointAndUp('md') + const tooltipContainerRef = useRef | null>(null) + const mapInitializedRef = useRef(false) + + const isCawp = CAWP_METRICS.includes(metric.metricId) + const isPhrma = PHRMA_METRICS.includes(metric.metricId) + + const suppressedData = useMemo( + () => (isPhrma ? processPhrmaData(data, countColsMap) : data), + [data, isPhrma, countColsMap], + ) - const heightWidthRatio = overrideShapeWithCircle - ? HEIGHT_WIDTH_RATIO * 2 - : HEIGHT_WIDTH_RATIO + const dataWithHighestLowest: DataPoint[] = useMemo( + () => + !isUnknownsMap && !isMulti + ? embedHighestLowestGroups(suppressedData, highestLowestGroupsByFips) + : suppressedData, + [suppressedData, highestLowestGroupsByFips, isUnknownsMap, isMulti], + ) - const height = width * heightWidthRatio + const dimensions = useMemo(() => { + const heightWidthRatio = overrideShapeWithCircle + ? HEIGHT_WIDTH_RATIO * 2 + : HEIGHT_WIDTH_RATIO + return { + height: width * heightWidthRatio, + ratio: heightWidthRatio, + } + }, [width, overrideShapeWithCircle]) - const tooltipContainer = createTooltipContainer() + const cleanup = () => { + if (tooltipContainerRef.current) { + tooltipContainerRef.current.remove() + tooltipContainerRef.current = null + } + if (svgRef.current) { + const svg = d3.select(svgRef.current) + svg.selectAll('*').remove() + svg.on('.', null) + } + mapInitializedRef.current = false + } useEffect(() => { - if (!data.length || !svgRef.current || !width) return + if (!data?.length || !svgRef.current || !width) { + return + } const initializeMap = async () => { + if (mapInitializedRef.current) { + return + } + + tooltipContainerRef.current ??= createTooltipContainer() + const colorScale = createColorScale({ - data, + dataWithHighestLowest, metricId: metric.metricId, - scaleType: 'sequentialSymlog', colorScheme: mapConfig.scheme, + isUnknown: isUnknownsMap, + fips, + reverse: !mapConfig.higherIsBetter && !isUnknownsMap, + scaleConfig, + isPhrma, }) - const features = await createFeatures({ - showCounties, - parentFips: fips.code, - geoData, - }) - - const projection = createProjection({ fips, width, height, features }) + const features = await createFeatures(showCounties, fips.code, geoData) - const tooltipLabel = createTooltipLabel( - metric, - activeDemographicGroup, - demographicType, - isUnknownsMap, + const projection = createProjection( + fips, + width, + dimensions.height, + features, ) - const tooltipPairs = getTooltipPairs(tooltipLabel) - renderMap({ svgRef, geoData: { features, projection }, - data, + dataWithHighestLowest, metric, width, - height, - tooltipContainer, - tooltipPairs, - tooltipLabel, + height: dimensions.height, + tooltipContainer: tooltipContainerRef.current!, showCounties, - updateFipsCallback, - mapConfig, colorScale, fips, isMobile, + activeDemographicGroup, + demographicType, + isCawp, + countColsMap, + isUnknownsMap, + extremesMode, + mapConfig, + signalListeners, }) + + mapInitializedRef.current = true } - initializeMap() - return () => { - tooltipContainer.remove() - } + + cleanup() + initializeMap().catch((error) => { + console.error('Error initializing map:', error) + }) + + return cleanup }, [ data, geoData, width, - height, + dimensions.height, showCounties, overrideShapeWithCircle, metric, + dataWithHighestLowest, + mapConfig, + fips, + isMobile, + activeDemographicGroup, + demographicType, + isCawp, + countColsMap, + isUnknownsMap, + scaleConfig, + signalListeners, + extremesMode, ]) return (
) diff --git a/frontend/src/charts/choroplethMap/mapHelpers.ts b/frontend/src/charts/choroplethMap/mapHelpers.ts index b5d17795cf..59da0e76f0 100644 --- a/frontend/src/charts/choroplethMap/mapHelpers.ts +++ b/frontend/src/charts/choroplethMap/mapHelpers.ts @@ -2,67 +2,30 @@ import * as d3 from 'd3' import type { FeatureCollection } from 'geojson' import { feature } from 'topojson-client' import { GEOGRAPHIES_DATASET_ID } from '../../data/config/MetadataMap' +import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import type { DemographicType } from '../../data/query/Breakdowns' +import type { Fips } from '../../data/utils/Fips' import { het } from '../../styles/DesignTokens' -import { getLegendDataBounds } from '../mapHelperFunctions' -import type { - CreateColorScaleProps, - CreateFeaturesProps, - CreateProjectionProps, - GetFillColorProps, -} from './types' - -const { altGrey: ALT_GREY } = het - -export const createColorScale = (props: CreateColorScaleProps) => { - const interpolatorFn = props.reverse - ? (t: number) => props.colorScheme(1 - t) - : props.colorScheme - - let colorScale: d3.ScaleSequential - - const [legendLowerBound, legendUpperBound] = getLegendDataBounds( - props.data, - props.metricId, - ) - - const [min, max] = props.fieldRange - ? [props.fieldRange.min, props.fieldRange.max] - : [legendLowerBound, legendUpperBound] - - if (min === undefined || max === undefined || isNaN(min) || isNaN(max)) { - return d3.scaleSequential(interpolatorFn).domain([0, 1]) - } - - const adjustedInterpolatorFn = (t: number) => { - const adjustedT = 0.1 + 0.9 * t // Scale the range to skip the lightest 10% - return interpolatorFn(adjustedT) - } - - if (props.scaleType === 'quantileSequential') { - const values = props.data - .map((d) => d[props.metricId]) - .filter((val) => val != null && !isNaN(val)) - colorScale = d3 - .scaleSequentialQuantile(adjustedInterpolatorFn) - .domain(values) - } else if (props.scaleType === 'sequentialSymlog') { - colorScale = d3 - .scaleSequentialSymlog() - .domain([min, max]) - .interpolator(adjustedInterpolatorFn) - } else { - console.error(`Unsupported scaleType: ${props.scaleType}.`) - return d3.scaleSequential(interpolatorFn).domain([0, 1]) - } - - return colorScale -} +import { type CountColsMap, DATA_SUPPRESSED } from '../mapGlobals' +import { + getCawpMapGroupDenominatorLabel, + getCawpMapGroupNumeratorLabel, + getMapGroupLabel, +} from '../mapHelperFunctions' +import type { MetricData } from './types' + +const { + altGrey: ALT_GREY, + white: WHITE, + greyGridColorDarker: BORDER_GREY, + borderColor, +} = het export const createFeatures = async ( - props: CreateFeaturesProps, + showCounties: boolean, + parentFips: string, + geoData?: Record, ): Promise => { - const { showCounties, parentFips, geoData } = props - const topology = geoData ?? JSON.parse( @@ -92,19 +55,110 @@ export const createFeatures = async ( } export const createProjection = ( - props: CreateProjectionProps, + fips: Fips, + width: number, + height: number, + features: FeatureCollection, ): d3.GeoProjection => { - const { fips, width, height, features } = props - const isTerritory = fips.isTerritory() || fips.getParentFips().isTerritory() return isTerritory ? d3.geoAlbers().fitSize([width, height], features) : d3.geoAlbersUsa().fitSize([width, height], features) } -export const getFillColor = (props: GetFillColorProps): string => { - const { d, dataMap, colorScale } = props +export const processPhrmaData = ( + data: Array>, + countColsMap: CountColsMap, +) => { + return data.map((row) => { + const newRow = { ...row } + + const processField = ( + fieldConfig: + | typeof countColsMap.numeratorConfig + | typeof countColsMap.denominatorConfig, + ) => { + if (!fieldConfig) return + + const value = row[fieldConfig.metricId] + if (value === null) return DATA_SUPPRESSED + if (value >= 0) return value.toLocaleString() + return value + } + + const numeratorId = countColsMap?.numeratorConfig?.metricId + const denominatorId = countColsMap?.denominatorConfig?.metricId + + if (numeratorId) { + newRow[numeratorId] = processField(countColsMap?.numeratorConfig) + } + if (denominatorId) { + newRow[denominatorId] = processField(countColsMap?.denominatorConfig) + } + + return newRow + }) +} + +export const getNumeratorPhrase = ( + isCawp: boolean, + countColsMap: any, + demographicType: DemographicType, + activeDemographicGroup: string, +): string => { + if (isCawp) { + return getCawpMapGroupNumeratorLabel(countColsMap, activeDemographicGroup) + } + + return getMapGroupLabel( + demographicType, + activeDemographicGroup, + countColsMap?.numeratorConfig?.shortLabel ?? '', + ) +} - const value = dataMap.get(d.id as string) - return value !== undefined ? colorScale(value) : ALT_GREY +export const getDenominatorPhrase = ( + isCawp: boolean, + countColsMap: any, + demographicType: DemographicType, + activeDemographicGroup: string, +): string => { + if (isCawp) { + return getCawpMapGroupDenominatorLabel(countColsMap) + } + + return getMapGroupLabel( + demographicType, + activeDemographicGroup, + countColsMap?.denominatorConfig?.shortLabel ?? '', + ) +} + +export const createDataMap = ( + dataWithHighestLowest: any[], + tooltipLabel: string, + metric: MetricConfig, + numeratorPhrase: string, + denominatorPhrase: string, + countColsMap: any, +): Map => { + return new Map( + dataWithHighestLowest.map((d) => [ + d.fips, + { + [tooltipLabel]: d[metric.metricId], + value: d[metric.metricId], + ...(countColsMap?.numeratorConfig && { + [`# ${numeratorPhrase}`]: d[countColsMap.numeratorConfig.metricId], + }), + ...(countColsMap?.denominatorConfig && { + [`# ${denominatorPhrase}`]: + d[countColsMap.denominatorConfig.metricId], + }), + ...(d.highestGroup && { ['Highest rate group']: d.highestGroup }), + ...(d.lowestGroup && { ['Lowest rate group']: d.lowestGroup }), + ...(d.rating && { ['County SVI']: d.rating }), + }, + ]), + ) } diff --git a/frontend/src/charts/choroplethMap/mapLegendUtils.ts b/frontend/src/charts/choroplethMap/mapLegendUtils.ts index 8d2c4ef269..24e84de577 100644 --- a/frontend/src/charts/choroplethMap/mapLegendUtils.ts +++ b/frontend/src/charts/choroplethMap/mapLegendUtils.ts @@ -2,17 +2,17 @@ import * as d3 from 'd3' import type { MetricId } from '../../data/config/MetricConfigTypes' import { het } from '../../styles/DesignTokens' import { calculateLegendColorCount } from '../mapHelperFunctions' -import type { DataPoint } from './types' +import type { ColorScale, DataPoint } from './types' const { altGrey } = het export const createUnknownLegend = ( legendGroup: d3.Selection, props: { - data: DataPoint[] + dataWithHighestLowest: DataPoint[] metricId: MetricId width: number - colorScale: d3.ScaleSequential + colorScale: ColorScale title: string isMobile: boolean isPct?: boolean @@ -24,7 +24,7 @@ export const createUnknownLegend = ( const [legendLowerBound, legendUpperBound] = colorScale.domain() const tickCount = isMobile ? 3 - : calculateLegendColorCount(props.data, props.metricId) + : calculateLegendColorCount(props.dataWithHighestLowest, props.metricId) const ticks = d3 .scaleLinear() diff --git a/frontend/src/charts/choroplethMap/renderMap.tsx b/frontend/src/charts/choroplethMap/renderMap.tsx index 07a887405f..b27ee57b84 100644 --- a/frontend/src/charts/choroplethMap/renderMap.tsx +++ b/frontend/src/charts/choroplethMap/renderMap.tsx @@ -1,31 +1,56 @@ import * as d3 from 'd3' -import { Fips } from '../../data/utils/Fips' import { het } from '../../styles/DesignTokens' import { getCountyAddOn } from '../mapHelperFunctions' -import { getFillColor } from './mapHelpers' +import { getFillColor } from './colorSchemes' +import { + createDataMap, + getDenominatorPhrase, + getNumeratorPhrase, +} from './mapHelpers' import { createUnknownLegend } from './mapLegendUtils' -import { getTooltipContent } from './tooltipUtils' -import type { InitializeSvgProps, RenderMapProps, TooltipPairs } from './types' +import { generateTooltipHtml, getTooltipLabel } from './tooltipUtils' +import type { + InitializeSvgProps, + MouseEventHandlerProps, + RenderMapProps, +} from './types' + +const { + darkBlue: DARK_BLUE, + redOrange: RED_ORANGE, + white: WHITE, + borderColor: BORDER_GREY, +} = het -const { darkBlue: DARK_BLUE, redOrange: RED_ORANGE } = het const STROKE_WIDTH = 0.5 -const TOOLTIP_OFFSET = { x: 10, y: 10 } +const TOOLTIP_OFFSET = { x: 10, y: 10 } as const +const MARGIN = { top: 20, right: 20, bottom: 20, left: 20 } as const -export const renderMap = (props: RenderMapProps) => { - const { features, projection } = props.geoData - const geographyType = getCountyAddOn(props.fips, props.showCounties) - const { - colorScale, - height, - width, - svgRef, - tooltipContainer, - isMobile, - data, - metric, - } = props +export const renderMap = ({ + geoData, + fips, + showCounties, + colorScale, + height, + width, + svgRef, + tooltipContainer, + isMobile, + metric, + extremesMode, + dataWithHighestLowest, + isUnknownsMap, + isCawp, + activeDemographicGroup, + demographicType, + countColsMap, + mapConfig, + hideLegend, + signalListeners, +}: RenderMapProps) => { + const { features, projection } = geoData + const geographyType = getCountyAddOn(fips, showCounties) - // Clear existing SVG content and initialize d3.select(svgRef.current).selectAll('*').remove() const { legendGroup, mapGroup } = initializeSvg({ svgRef, @@ -37,8 +62,33 @@ export const renderMap = (props: RenderMapProps) => { projection.fitSize([width, height * 0.8], features) const path = d3.geoPath(projection) - const dataMap = new Map( - props.data.map((d) => [d.fips, d[props.metric.metricId]]), + const tooltipLabel = getTooltipLabel( + isUnknownsMap, + metric, + isCawp, + activeDemographicGroup, + demographicType, + ) + const numeratorPhrase = getNumeratorPhrase( + isCawp, + countColsMap, + demographicType, + activeDemographicGroup, + ) + const denominatorPhrase = getDenominatorPhrase( + isCawp, + countColsMap, + demographicType, + activeDemographicGroup, + ) + + const dataMap = createDataMap( + dataWithHighestLowest, + tooltipLabel, + metric, + numeratorPhrase, + denominatorPhrase, + countColsMap, ) // Draw map @@ -47,52 +97,58 @@ export const renderMap = (props: RenderMapProps) => { .data(features.features) .join('path') .attr('d', (d) => path(d) || '') - .attr('fill', (d) => getFillColor({ d, dataMap, colorScale })) - .attr('stroke', '#fff') + .attr('fill', (d) => + getFillColor({ + d, + dataMap, + colorScale, + extremesMode, + zeroColor: mapConfig.min, + countyColor: mapConfig.mid, + fips, + }), + ) + .attr('stroke', extremesMode ? BORDER_GREY : WHITE) .attr('stroke-width', STROKE_WIDTH) .on('mouseover', (event, d) => - handleMouseEvent( - 'mouseover', - event, - d, - props.tooltipPairs, - props.colorScale, + handleMouseEvent('mouseover', event, d, { + colorScale, + metric, dataMap, tooltipContainer, geographyType, - ), + extremesMode, + mapConfig, + }), ) .on('mousemove', (event, d) => - handleMouseEvent( - 'mousemove', - event, - d, - props.tooltipPairs, - props.colorScale, + handleMouseEvent('mousemove', event, d, { + colorScale, + metric, dataMap, tooltipContainer, geographyType, - ), + extremesMode, + mapConfig, + }), ) .on('mouseout', (event, d) => - handleMouseEvent( - 'mouseout', - event, - d, - props.tooltipPairs, - props.colorScale, + handleMouseEvent('mouseout', event, d, { + colorScale, + metric, dataMap, tooltipContainer, geographyType, - ), + extremesMode, + mapConfig, + }), ) - .on('click', (event, d) => handleMapClick(d, props.updateFipsCallback)) + .on('click', signalListeners.click) - if (!props.hideLegend && !props.fips.isCounty()) { - const { metricId } = metric + if (!hideLegend && !fips.isCounty() && isUnknownsMap) { createUnknownLegend(legendGroup, { - data, - metricId, + dataWithHighestLowest, + metricId: metric.metricId, width, colorScale, title: '% unknown', @@ -102,12 +158,16 @@ export const renderMap = (props: RenderMapProps) => { } } -const initializeSvg = (props: InitializeSvgProps) => { - const margin = { top: 20, right: 20, bottom: 20, left: 20 } +const initializeSvg = ({ + svgRef, + width, + height, + isMobile, +}: InitializeSvgProps) => { const svg = d3 - .select(props.svgRef.current) - .attr('width', props.width) - .attr('height', props.height) + .select(svgRef.current) + .attr('width', width) + .attr('height', height) return { svg, @@ -116,53 +176,71 @@ const initializeSvg = (props: InitializeSvgProps) => { .attr('class', 'legend-container') .attr( 'transform', - `translate(${margin.left}, ${props.isMobile ? 0 : margin.top})`, + `translate(${MARGIN.left}, ${isMobile ? 0 : MARGIN.top})`, ), mapGroup: svg .append('g') .attr('class', 'map-container') .attr( 'transform', - `translate(${margin.left}, ${props.isMobile ? margin.top + 10 : margin.top + 50})`, + `translate(${MARGIN.left}, ${isMobile ? MARGIN.top + 10 : MARGIN.top + 50})`, ), } } const handleMouseEvent = ( - type: 'mouseover' | 'mousemove' | 'mouseout', + type: 'mouseover' | 'mouseout' | 'mousemove', event: any, d: any, - tooltipPairs: TooltipPairs, - colorScale: d3.ScaleSequential, - dataMap?: Map, - tooltipContainer?: d3.Selection, - geographyType?: string, + props: MouseEventHandlerProps, ) => { - if (type === 'mouseover' && d && dataMap) { - const value = dataMap.get(d.id as string) - d3.select(event.currentTarget) - .attr('fill', value !== undefined ? DARK_BLUE : RED_ORANGE) - .style('cursor', 'pointer') - tooltipContainer - ?.style('visibility', 'visible') - .html(getTooltipContent(d, value, tooltipPairs, geographyType)) - } else if (type === 'mousemove') { - tooltipContainer - ?.style('top', `${event.pageY + TOOLTIP_OFFSET.y}px`) - .style('left', `${event.pageX + TOOLTIP_OFFSET.x}px`) - } else if (type === 'mouseout' && d && dataMap) { - d3.select(event.currentTarget).attr( - 'fill', - getFillColor({ d, dataMap, colorScale }), - ) - tooltipContainer?.style('visibility', 'hidden').html('') - } -} + const { + colorScale, + metric, + dataMap, + tooltipContainer, + geographyType, + extremesMode, + mapConfig, + fips, + } = props + + if (!tooltipContainer) return + + switch (type) { + case 'mouseover': { + if (!d || !dataMap) return + const value = dataMap.get(d.id as string)?.value + + d3.select(event.currentTarget) + .attr('fill', value !== undefined ? DARK_BLUE : RED_ORANGE) + .style('cursor', 'pointer') -const handleMapClick = (d: any, updateFipsCallback: (fips: Fips) => void) => { - const clickedFips = d.id as string - if (clickedFips) { - updateFipsCallback(new Fips(clickedFips)) - location.hash = '#unknown-demographic-map' + const tooltipHtml = generateTooltipHtml(d, dataMap, metric, geographyType) + tooltipContainer.style('visibility', 'visible').html(tooltipHtml) + break + } + case 'mousemove': { + tooltipContainer + .style('top', `${event.pageY + TOOLTIP_OFFSET.y}px`) + .style('left', `${event.pageX + TOOLTIP_OFFSET.x}px`) + break + } + case 'mouseout': { + d3.select(event.currentTarget).attr( + 'fill', + getFillColor({ + d, + dataMap, + colorScale, + extremesMode, + zeroColor: mapConfig?.min || '', + countyColor: mapConfig?.mid || '', + fips, + }), + ) + tooltipContainer.style('visibility', 'hidden').html('') + break + } } } diff --git a/frontend/src/charts/choroplethMap/tooltipUtils.ts b/frontend/src/charts/choroplethMap/tooltipUtils.ts index 7a3bd161f4..8fcd7dea4b 100644 --- a/frontend/src/charts/choroplethMap/tooltipUtils.ts +++ b/frontend/src/charts/choroplethMap/tooltipUtils.ts @@ -1,66 +1,65 @@ import * as d3 from 'd3' import type { MetricConfig } from '../../data/config/MetricConfigTypes' +import { isPctType } from '../../data/config/MetricConfigUtils' import { CAWP_METRICS, getWomenRaceLabel, } from '../../data/providers/CawpProvider' import type { DemographicType } from '../../data/query/Breakdowns' -import type { DemographicGroup } from '../../data/utils/Constants' +import { LESS_THAN_POINT_1 } from '../../data/utils/Constants' import { het } from '../../styles/DesignTokens' import { getMapGroupLabel } from '../mapHelperFunctions' -import type { TooltipFeature, TooltipPairs } from './types' +import type { MetricData } from './types' -const { white, greyGridColorDarker } = het +const { white: WHITE, greyGridColorDarker: BORDER_GREY, borderColor } = het -/** - * Creates and styles a tooltip container. - * @returns {d3.Selection} A D3 selection for the tooltip container. - */ export const createTooltipContainer = () => { return d3 .select('body') .append('div') .style('position', 'absolute') .style('visibility', 'hidden') - .style('background-color', white) - .style('border', `1px solid ${greyGridColorDarker}`) + .style('background-color', WHITE) + .style('border', `1px solid ${BORDER_GREY}`) .style('border-radius', '4px') .style('padding', '8px') .style('font-size', '12px') .style('z-index', '1000') } -/** - * Formats the tooltip content based on the provided data. - */ - -export function getTooltipContent( - feature: TooltipFeature, +export const formatMetricValue = ( value: number | undefined, - tooltipPairs: TooltipPairs, - geographyType?: any, -): string { - const name = feature.properties?.name || String(feature.id) - return ` -
- ${name} ${geographyType}
- ${Object.entries(tooltipPairs) - .map(([label, formatter]) => `${label}: ${formatter(value)}`) - .join('
')} -
- ` + metricConfig: MetricConfig, +): string => { + if (value === undefined) return 'no data' + + if (metricConfig.type === 'per100k') { + if (value < 0.1) return `${LESS_THAN_POINT_1} per 100k` + return `${d3.format(',.2s')(value)} per 100k` + } + + if (isPctType(metricConfig.type)) { + return `${d3.format('d')(value)}%` + } + + return d3.format(',.2r')(value) } -export const createTooltipLabel = ( +export const getTooltipLabel = ( + isUnknownsMap: boolean | undefined, metric: MetricConfig, - activeDemographicGroup: DemographicGroup, + isCawp: boolean, + activeDemographicGroup: string, demographicType: DemographicType, - isUnknownsMap?: boolean, ): string => { - if (isUnknownsMap) return metric.unknownsVegaLabel || '% unknown' + if (isUnknownsMap) { + return metric.unknownsVegaLabel || '% unknown' + } + if (CAWP_METRICS.includes(metric.metricId)) { return `Rate — ${getWomenRaceLabel(activeDemographicGroup)}` } + return getMapGroupLabel( demographicType, activeDemographicGroup, @@ -68,9 +67,45 @@ export const createTooltipLabel = ( ) } -export const getTooltipPairs = (tooltipLabel: string): TooltipPairs => { - return { - [tooltipLabel]: (value: string | number | undefined) => - value !== undefined ? value.toString() : 'no data', +export const generateTooltipHtml = ( + feature: any, + dataMap: Map, + metricConfig: MetricConfig, + geographyType: string = '', +) => { + const name = feature.properties?.name || String(feature.id) + const data = dataMap.get(feature.id as string) + + if (!data) { + return ` +
+ ${name} ${geographyType}
+ No data available +
+ ` } + + const entries = Object.entries(data) + .filter(([key]) => !(key === 'County SVI' && geographyType !== 'County')) + .filter(([key]) => key !== 'value') + + const [firstLabel, firstValue] = entries[0] ?? ['', 0] + const remainingEntries = entries.slice(1) + + return ` +
+ ${name} ${geographyType} +
+
+ ${firstLabel}: ${formatMetricValue(firstValue as number, metricConfig)} +
+ ${remainingEntries + .map( + ([label, value]) => + `
${label}: ${value}
`, + ) + .join('')} +
+
+ ` } diff --git a/frontend/src/charts/choroplethMap/types.ts b/frontend/src/charts/choroplethMap/types.ts index a74164915c..be0eaa85c2 100644 --- a/frontend/src/charts/choroplethMap/types.ts +++ b/frontend/src/charts/choroplethMap/types.ts @@ -1,4 +1,4 @@ -import * as d3 from 'd3' +import type * as d3 from 'd3' import type { Feature, FeatureCollection, @@ -7,7 +7,9 @@ import type { } from 'geojson' import type { RefObject } from 'react' import type { Topology } from 'topojson-specification' +import type { ColorScheme } from 'vega' import type { + MapConfig, MetricConfig, MetricId, } from '../../data/config/MetricConfigTypes' @@ -17,25 +19,21 @@ import type { FieldRange } from '../../data/utils/DatasetTypes' import type { Fips } from '../../data/utils/Fips' import type { CountColsMap, HighestLowest } from '../mapGlobals' -export const MAP_SCHEMES = { - default: d3.interpolateGreens, - women: d3.interpolatePlasma, - men: d3.interpolateInferno, - medicare: d3.interpolateViridis, - unknown: d3.interpolateGnBu, - youth: d3.interpolateReds, -} +export type ColorScale = + | d3.ScaleSequential + | d3.ScaleThreshold + | d3.ScaleQuantile export interface ChoroplethMapProps { activeDemographicGroup: DemographicGroup countColsMap: CountColsMap demographicType: DemographicType - data: DataPoint[] + data: Array> extremesMode: boolean fips: Fips fieldRange?: FieldRange filename?: string - geoData: Topology + geoData?: Record hideLegend?: boolean hideMissingDataTooltip?: boolean highestLowestGroupsByFips?: Record @@ -55,16 +53,25 @@ export interface ChoroplethMapProps { titles?: { subtitle?: string } - updateFipsCallback: (fips: Fips) => void + scaleConfig?: { + domain: number[] + range: string[] + } } export interface CreateColorScaleProps { - data: DataPoint[] + dataWithHighestLowest: DataPoint[] metricId: MetricId - scaleType: 'quantileSequential' | 'sequentialSymlog' - colorScheme: (t: number) => string + colorScheme: ColorScheme | ((t: number) => string) reverse?: boolean fieldRange?: FieldRange + isUnknown?: boolean + fips: Fips + scaleConfig?: { + domain: number[] + range: string[] + } + isPhrma: boolean } export type CreateFeaturesProps = { @@ -83,14 +90,21 @@ export type CreateProjectionProps = { export type DataPoint = { fips: string fips_name: string + highestGroup?: string + lowestGroup?: string + rating?: string } & { [key in MetricId]: any } export type GetFillColorProps = { d: Feature - dataMap: Map - colorScale: d3.ScaleSequential + dataMap: Map + colorScale: ColorScale + extremesMode?: boolean + zeroColor: string + countyColor: string + fips?: Fips } export type HetRow = DataPoint & { @@ -104,16 +118,16 @@ export type InitializeSvgProps = { isMobile: boolean } -export interface MapConfig { - scheme: (t: number) => string - min: string - mid: string - higherIsBetter?: boolean +export interface MetricData { + [key: string]: string | number | undefined } export type RenderMapProps = { - colorScale: d3.ScaleSequential - data: DataPoint[] + activeDemographicGroup: DemographicGroup + colorScale: ColorScale + countColsMap: CountColsMap + dataWithHighestLowest: DataPoint[] + demographicType: DemographicType geoData: { features: FeatureCollection projection: d3.GeoProjection @@ -121,17 +135,17 @@ export type RenderMapProps = { height: number hideLegend?: boolean isUnknownsMap?: boolean - mapConfig: MapConfig metric: MetricConfig showCounties: boolean svgRef: RefObject tooltipContainer: d3.Selection - tooltipPairs: TooltipPairs - tooltipLabel: string - updateFipsCallback: (fips: Fips) => void width: number fips: Fips isMobile: boolean + isCawp: boolean + extremesMode: boolean + mapConfig: MapConfig + signalListeners: any } export type TooltipFeature = { @@ -143,6 +157,17 @@ export type TooltipPairs = { [key: string]: (value: number | string | undefined) => string } +export interface MouseEventHandlerProps { + colorScale: ColorScale + metric: MetricConfig + dataMap: Map + tooltipContainer: d3.Selection + geographyType?: string + extremesMode?: boolean + mapConfig?: MapConfig + fips?: Fips +} + /** * Extended Window interface for file system access */ diff --git a/frontend/src/data/config/MetricConfigTypes.ts b/frontend/src/data/config/MetricConfigTypes.ts index 477adbe8c7..7990e502a5 100644 --- a/frontend/src/data/config/MetricConfigTypes.ts +++ b/frontend/src/data/config/MetricConfigTypes.ts @@ -104,7 +104,7 @@ export interface MetricConfig { } export interface MapConfig { - scheme: ColorScheme + scheme: ColorScheme | ((t: number) => string) min: string mid: string higherIsBetter?: boolean diff --git a/frontend/src/styles/DesignTokens.ts b/frontend/src/styles/DesignTokens.ts index c84c08d4bb..5f7fa8ff97 100644 --- a/frontend/src/styles/DesignTokens.ts +++ b/frontend/src/styles/DesignTokens.ts @@ -94,6 +94,15 @@ const het = { mapWomenLighter: '#f48849', mapWomenLightest: '#febc2b', mapWomenMid: '#b93389', + mapYouthDarkZero: '#5a2f2a', + mapYouthDarkest: '#772f2a', + mapYouthDarker: '#a33a2b', + mapYouthDark: '#c34e27', + mapYouthMid: '#e06226', + mapYouthLight: '#e87b26', + mapYouthLighter: '#f09726', + mapYouthLightest: '#f4b02a', + mapYouthLightZero: '#f7d136', methodologyGreen: '#B5C7C2', navlinkColor: '#202124', redOrange: '#ed573f',