@cdc/map 4.26.3 → 4.26.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CONFIG.md +268 -0
  2. package/README.md +74 -24
  3. package/dist/cdcmap-CY9IcPSi.es.js +6 -0
  4. package/dist/cdcmap-DlpiY3fQ.es.js +4 -0
  5. package/dist/cdcmap.js +29168 -27482
  6. package/examples/{testing-layer-2.json → __data__/testing-layer-2.json} +1 -1
  7. package/examples/{testing-layer.json → __data__/testing-layer.json} +1 -1
  8. package/examples/county-hsa-toggle.json +51993 -0
  9. package/examples/custom-map-layers.json +2 -2
  10. package/examples/default-county.json +6 -3
  11. package/examples/minimal-example.json +73 -0
  12. package/examples/private/annotation-bug.json +2 -2
  13. package/examples/private/css-issue.json +314 -0
  14. package/examples/private/region-breaking.json +1639 -0
  15. package/examples/private/test1.json +27247 -0
  16. package/package.json +4 -4
  17. package/src/CdcMapComponent.tsx +107 -14
  18. package/src/_stories/CdcMap.AltText.stories.tsx +122 -0
  19. package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +600 -0
  20. package/src/_stories/CdcMap.Editor.DataTableSectionTests.stories.tsx +404 -0
  21. package/src/_stories/CdcMap.Editor.FiltersSectionTests.stories.tsx +229 -0
  22. package/src/_stories/CdcMap.Editor.GeneralSectionTests.stories.tsx +262 -0
  23. package/src/_stories/CdcMap.Editor.LegendSectionTests.stories.tsx +541 -0
  24. package/src/_stories/CdcMap.Editor.MultiCountryWorldMapTests.stories.tsx +359 -0
  25. package/src/_stories/CdcMap.Editor.PatternSettingsSectionTests.stories.tsx +516 -0
  26. package/src/_stories/CdcMap.Editor.SmallMultiplesSectionTests.stories.tsx +165 -0
  27. package/src/_stories/CdcMap.Editor.TextAnnotationsSectionTests.stories.tsx +145 -0
  28. package/src/_stories/CdcMap.Editor.TypeSectionTests.stories.tsx +312 -0
  29. package/src/_stories/CdcMap.Editor.VisualSectionTests.stories.tsx +359 -0
  30. package/src/_stories/CdcMap.Editor.ZoomControlsTests.stories.tsx +88 -0
  31. package/src/_stories/CdcMap.FocusVisibility.stories.tsx +87 -0
  32. package/src/_stories/CdcMap.HiddenMount.stories.tsx +69 -0
  33. package/src/_stories/CdcMap.ResetBehavior.stories.tsx +32 -0
  34. package/src/_stories/CdcMap.Zoom.stories.tsx +111 -0
  35. package/src/_stories/{CdcMap.stories.tsx → CdcMap.smoke.stories.tsx} +60 -0
  36. package/src/_stories/_mock/alt_text_metadata.json +65 -0
  37. package/src/_stories/_mock/legends/legend-tests.json +3 -3
  38. package/src/_stories/_mock/world-bubble-reset.json +138 -0
  39. package/src/_stories/_mock/world-data-zoom-filters.json +166 -0
  40. package/src/components/Annotation/AnnotationList.tsx +1 -1
  41. package/src/components/BubbleList.tsx +13 -0
  42. package/src/components/EditorPanel/components/EditorPanel.tsx +637 -382
  43. package/src/components/EditorPanel/components/HexShapeSettings.tsx +1 -1
  44. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +112 -117
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +26 -13
  46. package/src/components/EditorPanel/components/editorPanel.styles.css +22 -2
  47. package/src/components/FilterControls.tsx +21 -0
  48. package/src/components/Legend/components/Legend.tsx +3 -3
  49. package/src/components/Legend/components/LegendItem.Hex.tsx +4 -2
  50. package/src/components/SmallMultiples/SmallMultiples.tsx +2 -2
  51. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +1 -1
  52. package/src/components/UsaMap/components/UsaMap.County.tsx +309 -108
  53. package/src/components/UsaMap/components/UsaMap.Region.tsx +5 -2
  54. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +33 -10
  55. package/src/components/UsaMap/components/UsaMap.State.tsx +10 -3
  56. package/src/components/UsaMap/data/cb_2019_us_county_20m.json +75817 -1
  57. package/src/components/UsaMap/data/hsa_fips_mapping.json +3144 -0
  58. package/src/components/WorldMap/WorldMap.tsx +37 -4
  59. package/src/components/WorldMap/data/world-topo.json +1 -1
  60. package/src/components/ZoomableGroup.tsx +23 -3
  61. package/src/components/filterControls.styles.css +6 -0
  62. package/src/data/initial-state.js +3 -0
  63. package/src/data/supported-counties.json +1 -1
  64. package/src/helpers/countyTerritories.ts +38 -0
  65. package/src/helpers/dataTableHelpers.ts +35 -6
  66. package/src/helpers/generateRuntimeFilters.ts +2 -1
  67. package/src/helpers/handleMapAriaLabels.ts +45 -30
  68. package/src/helpers/shouldAutoResetSingleStateZoom.ts +22 -0
  69. package/src/helpers/tests/countyTerritories.test.ts +87 -0
  70. package/src/helpers/tests/handleMapAriaLabels.test.ts +71 -0
  71. package/src/helpers/tests/shouldAutoResetSingleStateZoom.test.ts +71 -0
  72. package/src/hooks/useApplyTooltipsToGeo.tsx +7 -4
  73. package/src/hooks/useGeoClickHandler.ts +13 -1
  74. package/src/hooks/useMapLayers.tsx +1 -1
  75. package/src/hooks/useStateZoom.tsx +39 -20
  76. package/src/hooks/useTooltip.test.tsx +2 -16
  77. package/src/hooks/useTooltip.ts +18 -7
  78. package/src/index.jsx +5 -2
  79. package/src/scss/main.scss +6 -21
  80. package/src/scss/map.scss +20 -0
  81. package/src/store/map.actions.ts +5 -2
  82. package/src/store/map.reducer.ts +12 -3
  83. package/src/test/CdcMap.test.jsx +24 -0
  84. package/src/types/MapConfig.ts +11 -0
  85. package/src/types/MapContext.ts +6 -1
  86. package/topojson-updater/README.txt +1 -1
  87. package/dist/cdcmap-vr9HZwRt.es.js +0 -6
  88. package/examples/__data__/city-state-data.json +0 -668
  89. package/examples/city-state.json +0 -434
  90. package/examples/default-world-data.json +0 -1450
  91. package/examples/new-cities.json +0 -656
  92. package/src/_stories/CdcMap.Editor.stories.tsx +0 -3648
  93. package/topojson-updater/package-lock.json +0 -223
  94. /package/src/_stories/{CdcMap.ColumnWrap.stories.tsx → CdcMap.ColumnWrap.smoke.stories.tsx} +0 -0
  95. /package/src/_stories/{CdcMap.Defaults.stories.tsx → CdcMap.Defaults.smoke.stories.tsx} +0 -0
  96. /package/src/_stories/{CdcMap.DistrictOfColumbia.stories.tsx → CdcMap.DistrictOfColumbia.smoke.stories.tsx} +0 -0
  97. /package/src/_stories/{CdcMap.Filters.stories.tsx → CdcMap.Filters.smoke.stories.tsx} +0 -0
  98. /package/src/_stories/{CdcMap.Legend.Gradient.stories.tsx → CdcMap.Legend.Gradient.smoke.stories.tsx} +0 -0
  99. /package/src/_stories/{CdcMap.Legend.stories.tsx → CdcMap.Legend.smoke.stories.tsx} +0 -0
  100. /package/src/_stories/{CdcMap.Patterns.stories.tsx → CdcMap.Patterns.smoke.stories.tsx} +0 -0
  101. /package/src/_stories/{CdcMap.SmallMultiples.stories.tsx → CdcMap.SmallMultiples.smoke.stories.tsx} +0 -0
  102. /package/src/_stories/{CdcMap.Table.stories.tsx → CdcMap.Table.smoke.stories.tsx} +0 -0
  103. /package/src/_stories/{CdcMap.ZeroColor.stories.tsx → CdcMap.ZeroColor.smoke.stories.tsx} +0 -0
  104. /package/src/_stories/{GoogleMap.stories.tsx → GoogleMap.smoke.stories.tsx} +0 -0
  105. /package/src/_stories/{UsaMap.NoData.stories.tsx → UsaMap.NoData.smoke.stories.tsx} +0 -0
@@ -0,0 +1,38 @@
1
+ export const US_TERRITORY_STATE_FIPS_PREFIXES = new Set(['60', '66', '69', '72', '78'])
2
+
3
+ export type CountyTerritoryVisibility = {
4
+ showTerritories: boolean
5
+ statePrefixes: Set<string>
6
+ countyIds: Set<string>
7
+ key: string
8
+ }
9
+
10
+ export const getCountyTerritoryVisibility = (
11
+ territoriesAlwaysShow: boolean | undefined,
12
+ runtimeData?: Record<string, any>
13
+ ): CountyTerritoryVisibility => {
14
+ const countyIds = new Set<string>()
15
+ const statePrefixes = new Set<string>()
16
+
17
+ if (runtimeData) {
18
+ Object.keys(runtimeData).forEach(key => {
19
+ if (key.length <= 2) return
20
+
21
+ const statePrefix = key.slice(0, 2)
22
+ if (!US_TERRITORY_STATE_FIPS_PREFIXES.has(statePrefix)) return
23
+
24
+ countyIds.add(key)
25
+ statePrefixes.add(statePrefix)
26
+ })
27
+ }
28
+
29
+ const showTerritories = territoriesAlwaysShow !== false && countyIds.size > 0
30
+ const key = `${showTerritories}:${showTerritories ? Array.from(countyIds).sort().join(',') : ''}`
31
+
32
+ return {
33
+ showTerritories,
34
+ statePrefixes,
35
+ countyIds,
36
+ key
37
+ }
38
+ }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  stateFipsToTwoDigit as stateFipsToAbbreviation,
3
- supportedStatesFipsCodes as supportedStateCodes
3
+ supportedStatesFipsCodes as supportedStateFipsCodes
4
4
  } from '../data/supported-geos'
5
5
 
6
6
  /**
@@ -13,18 +13,46 @@ export const shouldShowDataTable = (config: any, table: any, general: any, loadi
13
13
  /**
14
14
  * Filters county runtime data to a selected state code for data table display.
15
15
  * Keeps the original non-enumerable fromHash metadata when present.
16
+ * Fail-safe: if no recognizable state columns exist in the data, returns original
17
+ * data unfiltered (avoids breaking misconfigured datasets). If valid state columns
18
+ * exist but a state has no data, returns empty result as expected.
16
19
  */
17
20
  export const filterCountyTableRuntimeDataByStateCode = (runtimeData: any, stateCode: string, config?: any) => {
18
21
  if (!runtimeData || runtimeData.init || !stateCode) return runtimeData
19
22
 
20
- const filtered = {}
21
- const stateName = supportedStateCodes[stateCode]
22
- const stateAbbreviation = stateFipsToAbbreviation[stateCode]
23
+ const runtimeKeys = Object.keys(runtimeData)
24
+ if (runtimeKeys.length === 0) return runtimeData
25
+
26
+ const stateName = supportedStateFipsCodes?.[stateCode]
27
+ const stateAbbreviation = stateFipsToAbbreviation?.[stateCode]
23
28
  const normalizedSelectedStateCode = String(stateCode).replace(/^0+/, '')
24
29
  const paddedSelectedStateCode = normalizedSelectedStateCode.padStart(2, '0')
25
30
  const stateColumnNames = Object.values(config?.columns || {})
26
31
  .map((column: any) => column?.name)
27
- .filter((name: string) => !!name && /(state|territory|fips)/i.test(name))
32
+ .filter((name: string) => !!name && /(state|territory|fips|jurisdiction)/i.test(name))
33
+
34
+ // Also check common state field names directly in the data rows
35
+ const commonStateFieldNames = [
36
+ 'jurisdiction',
37
+ 'state',
38
+ 'State',
39
+ 'state_name',
40
+ 'stateName',
41
+ 'State/Territory',
42
+ 'state_territory_name'
43
+ ]
44
+ const allStateColumns = [...new Set([...stateColumnNames, ...commonStateFieldNames])]
45
+
46
+ // Fail-safe: check if UIDs look like county FIPS codes (5 digits) OR if any state column exists in the data
47
+ const hasCountyFipsUids = runtimeKeys.some(uid => /^\d{5}$/.test(String(uid)))
48
+ const hasStateColumn = runtimeKeys.some(uid => allStateColumns.some(col => runtimeData[uid]?.[col] !== undefined))
49
+
50
+ // If data has neither county FIPS UIDs nor any recognizable state columns, don't filter
51
+ if (!hasCountyFipsUids && !hasStateColumn) {
52
+ return runtimeData
53
+ }
54
+
55
+ const filtered = {}
28
56
 
29
57
  if (runtimeData.fromHash !== undefined) {
30
58
  Object.defineProperty(filtered, 'fromHash', {
@@ -38,7 +66,8 @@ export const filterCountyTableRuntimeDataByStateCode = (runtimeData: any, stateC
38
66
  const normalizedUidPrefix = uidPrefix.startsWith('0') ? uidPrefix.slice(1) : uidPrefix
39
67
  const matchesUidPrefix =
40
68
  uidPrefix === paddedSelectedStateCode || normalizedUidPrefix === normalizedSelectedStateCode
41
- const matchesStateColumn = stateColumnNames.some((columnName: string) => {
69
+
70
+ const matchesStateColumn = allStateColumns.some((columnName: string) => {
42
71
  const rawValue = row?.[columnName]
43
72
  if (rawValue === undefined || rawValue === null) return false
44
73
 
@@ -13,6 +13,7 @@ export const generateRuntimeFilters = (state, hash, runtimeFilters) => {
13
13
  queryParameter,
14
14
  orderedValues,
15
15
  active,
16
+ defaultValue,
16
17
  values,
17
18
  type,
18
19
  showDropdown,
@@ -50,7 +51,7 @@ export const generateRuntimeFilters = (state, hash, runtimeFilters) => {
50
51
  newFilter.values = values
51
52
  newFilter.setByQueryParameter = setByQueryParameter
52
53
  handleSorting(newFilter)
53
- newFilter.active = active ?? values[0] // Default to first found value
54
+ newFilter.active = active ?? defaultValue ?? values[0] // Default to configured defaultValue, then first found value
54
55
  newFilter.filterStyle = state.filters[idx].filterStyle ? state.filters[idx].filterStyle : 'dropdown'
55
56
  newFilter.showDropdown = showDropdown
56
57
  newFilter.subGrouping = state.filters[idx].subGrouping
@@ -1,36 +1,51 @@
1
- export const handleMapAriaLabels = (state: MapConfig = '') => {
1
+ import type { AltTextConfig } from '@cdc/core/types/AltText'
2
+ import { resolveAltTextDescription } from '@cdc/core/helpers/resolveAltTextDescription'
3
+
4
+ const getAutoLabel = (state): string => {
5
+ const {
6
+ general: { title, geoType, statesPicked }
7
+ } = state
8
+ let ariaLabel = ''
9
+ switch (geoType) {
10
+ case 'world':
11
+ ariaLabel += 'World map'
12
+ break
13
+ case 'us':
14
+ ariaLabel += 'United States map'
15
+ break
16
+ case 'us-county':
17
+ ariaLabel += `United States county map`
18
+ break
19
+ case 'single-state':
20
+ ariaLabel += `${statesPicked.map(sp => sp.stateName).join(', ')} county map`
21
+ break
22
+ case 'us-region':
23
+ ariaLabel += `United States HHS Region map`
24
+ break
25
+ default:
26
+ ariaLabel = 'Data visualization container'
27
+ break
28
+ }
29
+
30
+ if (title) {
31
+ ariaLabel += ` with the title: ${title}`
32
+ }
33
+
34
+ return ariaLabel
35
+ }
36
+
37
+ export const handleMapAriaLabels = (state: {
38
+ general?: { geoType?: string; title?: string; statesPicked?: { stateName: string }[] }
39
+ altText?: AltTextConfig
40
+ dataMetadata?: Record<string, string>
41
+ }): string => {
2
42
  try {
3
- if (!state.general.geoType) throw Error('handleMapAriaLabels: no geoType found in state')
4
- const {
5
- general: { title, geoType, statesPicked }
6
- } = state
7
- let ariaLabel = ''
8
- switch (geoType) {
9
- case 'world':
10
- ariaLabel += 'World map'
11
- break
12
- case 'us':
13
- ariaLabel += 'United States map'
14
- break
15
- case 'us-county':
16
- ariaLabel += `United States county map`
17
- break
18
- case 'single-state':
19
- ariaLabel += `${statesPicked.map(sp => sp.stateName).join(', ')} county map`
20
- break
21
- case 'us-region':
22
- ariaLabel += `United States HHS Region map`
23
- break
24
- default:
25
- ariaLabel = 'Data visualization container'
26
- break
27
- }
43
+ if (!state.general?.geoType) throw Error('handleMapAriaLabels: no geoType found in state')
28
44
 
29
- if (title) {
30
- ariaLabel += ` with the title: ${title}`
31
- }
45
+ const title = getAutoLabel(state)
46
+ const description = resolveAltTextDescription(state.altText, state.dataMetadata)
32
47
 
33
- return ariaLabel
48
+ return description ? `${title}. ${description}` : title
34
49
  } catch (e) {
35
50
  console.error('COVE: ', e.message) // eslint-disable-line
36
51
  return 'Data visualization container'
@@ -0,0 +1,22 @@
1
+ type AutoResetArgs = {
2
+ isDashboard: boolean
3
+ previousRuntimeDataHash: number | null
4
+ nextRuntimeDataHash?: number
5
+ hasDashboardFilters?: boolean
6
+ allowMapZoom?: boolean
7
+ }
8
+
9
+ export const shouldAutoResetSingleStateZoom = ({
10
+ isDashboard,
11
+ previousRuntimeDataHash,
12
+ nextRuntimeDataHash,
13
+ hasDashboardFilters = false,
14
+ allowMapZoom = true
15
+ }: AutoResetArgs): boolean => {
16
+ if (!isDashboard || !allowMapZoom) return false
17
+ if (!hasDashboardFilters) return false
18
+ if (previousRuntimeDataHash === null) return false
19
+ if (nextRuntimeDataHash === undefined) return false
20
+
21
+ return previousRuntimeDataHash !== nextRuntimeDataHash
22
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getCountyTerritoryVisibility } from '../countyTerritories'
3
+
4
+ describe('countyTerritories', () => {
5
+ it('collects visible territory prefixes and county ids from runtime data', () => {
6
+ const runtimeData = {
7
+ '06001': { uid: '06001', value: 1 },
8
+ '72001': { uid: '72001', value: 2 }
9
+ }
10
+
11
+ const visibility = getCountyTerritoryVisibility(true, runtimeData)
12
+
13
+ expect(visibility.showTerritories).toBe(true)
14
+ expect(Array.from(visibility.statePrefixes).sort()).toEqual(['72'])
15
+ expect(Array.from(visibility.countyIds).sort()).toEqual(['72001'])
16
+ expect(visibility.key).toBe('true:72001')
17
+ })
18
+
19
+ it('collects multiple territory prefixes and ignores non-territory counties', () => {
20
+ const runtimeData = {
21
+ '72001': { uid: '72001', value: 1 },
22
+ '72003': { uid: '72003', value: 2 },
23
+ '78010': { uid: '78010', value: 3 },
24
+ '06001': { uid: '06001', value: 4 }
25
+ }
26
+
27
+ const visibility = getCountyTerritoryVisibility(true, runtimeData)
28
+
29
+ expect(Array.from(visibility.statePrefixes).sort()).toEqual(['72', '78'])
30
+ expect(Array.from(visibility.countyIds).sort()).toEqual(['72001', '72003', '78010'])
31
+ expect(visibility.key).toBe('true:72001,72003,78010')
32
+ })
33
+
34
+ it('hides county territories when the config flag is disabled even if territory data exists', () => {
35
+ const runtimeData = {
36
+ '72001': { uid: '72001', value: 1 }
37
+ }
38
+
39
+ const visibility = getCountyTerritoryVisibility(false, runtimeData)
40
+
41
+ expect(visibility.showTerritories).toBe(false)
42
+ expect(Array.from(visibility.statePrefixes).sort()).toEqual(['72'])
43
+ expect(Array.from(visibility.countyIds).sort()).toEqual(['72001'])
44
+ expect(visibility.key).toBe('false:')
45
+ })
46
+
47
+ it('hides county territories when the config flag is enabled but no territory data exists', () => {
48
+ const runtimeData = {
49
+ '06001': { uid: '06001', value: 1 }
50
+ }
51
+
52
+ const visibility = getCountyTerritoryVisibility(true, runtimeData)
53
+
54
+ expect(visibility.showTerritories).toBe(false)
55
+ expect(Array.from(visibility.statePrefixes).sort()).toEqual([])
56
+ expect(Array.from(visibility.countyIds).sort()).toEqual([])
57
+ expect(visibility.key).toBe('false:')
58
+ })
59
+
60
+ it('treats an omitted config flag as enabled by default but still hides territories when no territory data exists', () => {
61
+ const runtimeData = {
62
+ '06001': { uid: '06001', value: 1 }
63
+ }
64
+
65
+ const visibility = getCountyTerritoryVisibility(undefined, runtimeData)
66
+
67
+ expect(visibility.showTerritories).toBe(false)
68
+ expect(Array.from(visibility.statePrefixes)).toEqual([])
69
+ expect(Array.from(visibility.countyIds)).toEqual([])
70
+ expect(visibility.key).toBe('false:')
71
+ })
72
+
73
+ it('changes the key when visible territory county ids change within the same territory prefix', () => {
74
+ const firstVisibility = getCountyTerritoryVisibility(true, {
75
+ '72001': { uid: '72001', value: 1 }
76
+ })
77
+ const secondVisibility = getCountyTerritoryVisibility(true, {
78
+ '72003': { uid: '72003', value: 1 }
79
+ })
80
+
81
+ expect(Array.from(firstVisibility.statePrefixes)).toEqual(['72'])
82
+ expect(Array.from(secondVisibility.statePrefixes)).toEqual(['72'])
83
+ expect(firstVisibility.key).toBe('true:72001')
84
+ expect(secondVisibility.key).toBe('true:72003')
85
+ expect(firstVisibility.key).not.toBe(secondVisibility.key)
86
+ })
87
+ })
@@ -0,0 +1,71 @@
1
+ import { handleMapAriaLabels } from '../handleMapAriaLabels'
2
+
3
+ const baseState = {
4
+ general: {
5
+ geoType: 'us',
6
+ title: 'COVID-19 Cases by State',
7
+ statesPicked: []
8
+ }
9
+ }
10
+
11
+ describe('handleMapAriaLabels', () => {
12
+ it('returns auto-generated title for US map', () => {
13
+ const result = handleMapAriaLabels(baseState)
14
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
15
+ })
16
+
17
+ it('returns auto-generated title for world map', () => {
18
+ const result = handleMapAriaLabels({
19
+ general: { geoType: 'world', title: 'Global Data', statesPicked: [] }
20
+ })
21
+ expect(result).toBe('World map with the title: Global Data')
22
+ })
23
+
24
+ it('returns fallback when geoType is missing', () => {
25
+ const result = handleMapAriaLabels({ general: {} })
26
+ expect(result).toBe('Data visualization container')
27
+ })
28
+
29
+ describe('description', () => {
30
+ it('concatenates static description after title', () => {
31
+ const result = handleMapAriaLabels({
32
+ ...baseState,
33
+ altText: { type: 'static', value: 'Rates highest in the Southeast.' }
34
+ })
35
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State. Rates highest in the Southeast.')
36
+ })
37
+
38
+ it('concatenates description from metadata after title', () => {
39
+ const result = handleMapAriaLabels({
40
+ ...baseState,
41
+ altText: { type: 'metadata', metadataKey: 'altDescription' },
42
+ dataMetadata: { altDescription: 'Map shows elevated rates in southern states.' }
43
+ })
44
+ expect(result).toBe(
45
+ 'United States map with the title: COVID-19 Cases by State. Map shows elevated rates in southern states.'
46
+ )
47
+ })
48
+
49
+ it('returns title only when metadata key is missing', () => {
50
+ const result = handleMapAriaLabels({
51
+ ...baseState,
52
+ altText: { type: 'metadata', metadataKey: 'missing' },
53
+ dataMetadata: { other: 'value' }
54
+ })
55
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
56
+ })
57
+
58
+ it('returns title only when not configured', () => {
59
+ const result = handleMapAriaLabels(baseState)
60
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
61
+ })
62
+
63
+ it('returns title only when altText is empty object', () => {
64
+ const result = handleMapAriaLabels({
65
+ ...baseState,
66
+ altText: {}
67
+ })
68
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
69
+ })
70
+ })
71
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { shouldAutoResetSingleStateZoom } from '../shouldAutoResetSingleStateZoom'
3
+
4
+ describe('shouldAutoResetSingleStateZoom', () => {
5
+ it('returns false on the first runtime data load', () => {
6
+ expect(
7
+ shouldAutoResetSingleStateZoom({
8
+ isDashboard: true,
9
+ previousRuntimeDataHash: null,
10
+ nextRuntimeDataHash: 123,
11
+ hasDashboardFilters: true
12
+ })
13
+ ).toBe(false)
14
+ })
15
+
16
+ it('returns true when filtered dashboard runtime data changes after initial load', () => {
17
+ expect(
18
+ shouldAutoResetSingleStateZoom({
19
+ isDashboard: true,
20
+ previousRuntimeDataHash: 123,
21
+ nextRuntimeDataHash: 456,
22
+ hasDashboardFilters: true
23
+ })
24
+ ).toBe(true)
25
+ })
26
+
27
+ it('returns false when the runtime data hash is unchanged', () => {
28
+ expect(
29
+ shouldAutoResetSingleStateZoom({
30
+ isDashboard: true,
31
+ previousRuntimeDataHash: 123,
32
+ nextRuntimeDataHash: 123,
33
+ hasDashboardFilters: true
34
+ })
35
+ ).toBe(false)
36
+ })
37
+
38
+ it('returns false outside dashboards', () => {
39
+ expect(
40
+ shouldAutoResetSingleStateZoom({
41
+ isDashboard: false,
42
+ previousRuntimeDataHash: 123,
43
+ nextRuntimeDataHash: 456,
44
+ hasDashboardFilters: true
45
+ })
46
+ ).toBe(false)
47
+ })
48
+
49
+ it('returns false when map zoom is disabled', () => {
50
+ expect(
51
+ shouldAutoResetSingleStateZoom({
52
+ isDashboard: true,
53
+ previousRuntimeDataHash: 123,
54
+ nextRuntimeDataHash: 456,
55
+ hasDashboardFilters: true,
56
+ allowMapZoom: false
57
+ })
58
+ ).toBe(false)
59
+ })
60
+
61
+ it('returns false when dashboard filters are not present on the visualization', () => {
62
+ expect(
63
+ shouldAutoResetSingleStateZoom({
64
+ isDashboard: true,
65
+ previousRuntimeDataHash: 123,
66
+ nextRuntimeDataHash: 456,
67
+ hasDashboardFilters: false
68
+ })
69
+ ).toBe(false)
70
+ })
71
+ })
@@ -1,8 +1,7 @@
1
1
  import { type ReactNode, useContext } from 'react'
2
- import { displayGeoName, navigationHandler } from '../helpers'
2
+ import { navigationHandler } from '../helpers'
3
3
  import ConfigContext from '../context'
4
4
  import useTooltip from './useTooltip'
5
- import { supportedStatesFipsCodes } from './../data/supported-geos'
6
5
  import parse from 'html-react-parser'
7
6
  import isDomainExternal from '@cdc/core/helpers/isDomainExternal'
8
7
  import ExternalIcon from './../images/external-link.svg'
@@ -10,7 +9,7 @@ import ExternalIcon from './../images/external-link.svg'
10
9
  const useApplyTooltipsToGeo = () => {
11
10
  const { config, customNavigationHandler } = useContext(ConfigContext)
12
11
  const navigationColumnName = config.columns.navigate.name
13
- const { buildTooltip } = useTooltip({ config, displayGeoName, supportedStatesFipsCodes })
12
+ const { buildTooltip } = useTooltip(config)
14
13
 
15
14
  const applyTooltipsToGeo = (geoName: string, row: Object, returnType = 'string') => {
16
15
  let toolTipText: string | ReactNode = buildTooltip(row, geoName, '')
@@ -18,7 +17,11 @@ const useApplyTooltipsToGeo = () => {
18
17
  // We convert the markup into JSX and add a navigation link if it's going into a modal.
19
18
  if ('jsx' === returnType) {
20
19
  if (typeof toolTipText === 'string') {
21
- toolTipText = [<div key='modal-content'>{parse(toolTipText)}</div>]
20
+ toolTipText = [
21
+ <div key='modal-content' className='cove-prose'>
22
+ {parse(toolTipText)}
23
+ </div>
24
+ ]
22
25
  }
23
26
 
24
27
  if (config.columns.hasOwnProperty('navigate') && row[navigationColumnName]) {
@@ -9,6 +9,9 @@ const useGeoClickHandler = () => {
9
9
  config: state,
10
10
  setConfig,
11
11
  setSharedFilter,
12
+ clearSharedFilter,
13
+ hasActiveSharedFilter,
14
+ setSharedFilterValue,
12
15
  customNavigationHandler,
13
16
  interactionLabel
14
17
  } = useContext(ConfigContext)
@@ -16,7 +19,16 @@ const useGeoClickHandler = () => {
16
19
 
17
20
  const geoClickHandler = (geoDisplayName: string, geoData: object): void => {
18
21
  if (setSharedFilter) {
19
- setSharedFilter(state.uid, geoData)
22
+ // Get the column name for the filter (from dashboardFilters config)
23
+ const filterColumnName = state.dashboardFilters?.[0]?.columnName || state.columns?.geo?.name
24
+ const clickedValue = filterColumnName ? geoData[filterColumnName] : geoDisplayName
25
+
26
+ // Toggle behavior: if the clicked value matches the current filter value, clear it
27
+ if (hasActiveSharedFilter && setSharedFilterValue === clickedValue && clearSharedFilter) {
28
+ clearSharedFilter(state.uid)
29
+ } else {
30
+ setSharedFilter(state.uid, geoData)
31
+ }
20
32
  }
21
33
 
22
34
  // If world-geocode map zoom to geo point
@@ -53,7 +53,7 @@ export default function useMapLayers(config: MapConfig, setConfig, pathGenerator
53
53
  setConfig(newConfig)
54
54
  }
55
55
 
56
- const handleAddLayer = (e: Event) => {
56
+ const handleAddLayer = (e: MouseEvent<HTMLButtonElement>) => {
57
57
  e.preventDefault()
58
58
  const placeHolderLayer = {
59
59
  name: 'New Custom Layer',
@@ -19,7 +19,7 @@ interface StateData {
19
19
  }
20
20
 
21
21
  const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
22
- const { config, runtimeData, position, interactionLabel } = useContext<MapContext>(ConfigContext)
22
+ const { config, runtimeData, position, scale, translate, interactionLabel } = useContext<MapContext>(ConfigContext)
23
23
  const dispatch = useContext(MapDispatchContext)
24
24
 
25
25
  // Get statesPicked with memoization
@@ -72,8 +72,42 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
72
72
  return { projection, newProjection, stateCenter, bounds }
73
73
  }, [topoData, statesPicked])
74
74
 
75
+ const resetZoomState = useCallback(
76
+ ({ publishEvent = true }: { publishEvent?: boolean } = {}) => {
77
+ const nextCoordinates = projectionData.stateCenter
78
+ const alreadyAtDefaultPosition =
79
+ position.zoom === 1 &&
80
+ position.coordinates[0] === nextCoordinates[0] &&
81
+ position.coordinates[1] === nextCoordinates[1]
82
+ const alreadyAtDefaultTransform = scale === 1 && translate[0] === 0 && translate[1] === 0
83
+
84
+ if (alreadyAtDefaultPosition && alreadyAtDefaultTransform) return
85
+
86
+ dispatch({ type: 'SET_POSITION', payload: { coordinates: nextCoordinates, zoom: 1 } })
87
+ dispatch({ type: 'SET_TRANSLATE', payload: [0, 0] })
88
+ dispatch({ type: 'SET_SCALE', payload: 1 })
89
+
90
+ if (publishEvent) {
91
+ publishAnalyticsEvent({
92
+ vizType: 'map',
93
+ vizSubType: getVizSubType(config),
94
+ eventType: 'map_reset_zoom_level',
95
+ eventAction: 'click',
96
+ eventLabel: interactionLabel,
97
+ vizTitle: getVizTitle(config)
98
+ })
99
+ }
100
+ },
101
+ [config, dispatch, interactionLabel, position, projectionData.stateCenter, scale, translate]
102
+ )
103
+
75
104
  const setScaleAndTranslate = useCallback(
76
105
  (zoomFunction: string = '') => {
106
+ if (zoomFunction === 'reset') {
107
+ resetZoomState()
108
+ return
109
+ }
110
+
77
111
  const _prevPosition = position
78
112
  let newZoom = _prevPosition.zoom
79
113
  let newCoordinates = _prevPosition.coordinates
@@ -98,27 +132,11 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
98
132
  _prevPosition.coordinates[0] !== 0 && _prevPosition.coordinates[1] !== 0
99
133
  ? _prevPosition.coordinates
100
134
  : projectionData.stateCenter
101
- } else if (zoomFunction === 'reset') {
102
- newZoom = 1
103
- newCoordinates = projectionData.stateCenter
104
135
  }
105
136
 
106
137
  dispatch({ type: 'SET_POSITION', payload: { coordinates: newCoordinates, zoom: newZoom } })
107
-
108
- if (zoomFunction === 'reset') {
109
- dispatch({ type: 'SET_TRANSLATE', payload: [0, 0] }) // needed for state switcher
110
- dispatch({ type: 'SET_SCALE', payload: 1 }) // needed for state switcher
111
- publishAnalyticsEvent({
112
- vizType: 'map',
113
- vizSubType: getVizSubType(config),
114
- eventType: 'map_reset_zoom_level',
115
- eventAction: 'click',
116
- eventLabel: interactionLabel,
117
- vizTitle: getVizTitle(config)
118
- })
119
- }
120
138
  },
121
- [position, projectionData.stateCenter, interactionLabel, dispatch]
139
+ [config, dispatch, interactionLabel, position, projectionData.stateCenter, resetZoomState]
122
140
  )
123
141
 
124
142
  // Essential fix: Remove config from dependencies to prevent infinite loops
@@ -165,9 +183,9 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
165
183
 
166
184
  const handleZoomReset = useCallback(
167
185
  _setRuntimeData => {
168
- setScaleAndTranslate('reset')
186
+ resetZoomState()
169
187
  },
170
- [setScaleAndTranslate]
188
+ [resetZoomState]
171
189
  )
172
190
 
173
191
  return {
@@ -178,6 +196,7 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
178
196
  handleZoomOut,
179
197
  handleMoveEnd,
180
198
  handleZoomReset,
199
+ resetZoomState,
181
200
  projection: projectionData.projection,
182
201
  bounds: projectionData.bounds
183
202
  }
@@ -1,10 +1,6 @@
1
1
  import { renderHook } from '@testing-library/react'
2
2
  import useTooltip from './useTooltip'
3
3
 
4
- const supportedStatesFipsCodes = {
5
- '01': 'Alabama'
6
- }
7
-
8
4
  const createConfig = (hideGeoColumnInTooltip: boolean) => ({
9
5
  general: {
10
6
  geoType: 'world',
@@ -41,18 +37,8 @@ describe('useTooltip', () => {
41
37
  it('hides the geography column label in the tooltip body when configured', () => {
42
38
  const row = { Country: 'ssd', Value: 10 }
43
39
 
44
- const { result: visibleResult } = renderHook(() =>
45
- useTooltip({
46
- config: createConfig(false),
47
- supportedStatesFipsCodes
48
- })
49
- )
50
- const { result: hiddenResult } = renderHook(() =>
51
- useTooltip({
52
- config: createConfig(true),
53
- supportedStatesFipsCodes
54
- })
55
- )
40
+ const { result: visibleResult } = renderHook(() => useTooltip(createConfig(false) as any))
41
+ const { result: hiddenResult } = renderHook(() => useTooltip(createConfig(true) as any))
56
42
 
57
43
  const visibleTooltip = visibleResult.current.buildTooltip(row, 'South Sudan')
58
44
  const hiddenTooltip = hiddenResult.current.buildTooltip(row, 'South Sudan')