@cdc/map 4.25.10 → 4.26.1

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 (107) hide show
  1. package/.claude/agents/typescript-organizer.md +118 -0
  2. package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
  3. package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
  4. package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
  5. package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
  6. package/dist/cdcmap.js +58397 -55987
  7. package/examples/example-city-state.json +9 -1
  8. package/examples/multi-country-centering.json +45 -0
  9. package/examples/private/city_styles_variable.json +877 -0
  10. package/examples/private/colors-2.json +221 -0
  11. package/examples/private/colors.json +221 -0
  12. package/examples/private/map-filter-issue.json +2260 -0
  13. package/examples/private/map-legend.json +5303 -0
  14. package/index.html +27 -36
  15. package/package.json +6 -5
  16. package/src/CdcMapComponent.tsx +86 -26
  17. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  18. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  19. package/src/_stories/CdcMap.Editor.stories.tsx +3426 -0
  20. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  21. package/src/_stories/CdcMap.stories.tsx +116 -4
  22. package/src/_stories/_mock/column-wrap-test.json +265 -0
  23. package/src/_stories/_mock/multi-country-hide.json +78 -0
  24. package/src/_stories/_mock/multi-country.json +95 -0
  25. package/src/_stories/_mock/multi-state.json +887 -20403
  26. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  27. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  28. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  29. package/src/_stories/_mock/usa-state-gradient.json +3 -4
  30. package/src/components/BubbleList.tsx +1 -1
  31. package/src/components/CityList.tsx +24 -18
  32. package/src/components/EditorPanel/components/EditorPanel.tsx +2380 -2206
  33. package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +0 -19
  35. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
  36. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +351 -0
  37. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  38. package/src/components/Geo.tsx +20 -3
  39. package/src/components/Legend/components/Legend.tsx +58 -75
  40. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +1 -1
  41. package/src/components/Legend/components/index.scss +23 -6
  42. package/src/components/NavigationMenu.tsx +16 -13
  43. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  44. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  45. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  46. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  47. package/src/components/SmallMultiples/index.tsx +3 -0
  48. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +18 -3
  49. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  50. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  51. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +29 -9
  52. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +7 -0
  53. package/src/components/UsaMap/components/UsaMap.County.tsx +16 -4
  54. package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
  55. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +29 -12
  56. package/src/components/UsaMap/components/UsaMap.State.tsx +30 -5
  57. package/src/components/UsaMap/helpers/map.ts +2 -2
  58. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  59. package/src/components/WorldMap/WorldMap.tsx +81 -11
  60. package/src/data/initial-state.js +11 -0
  61. package/src/data/supported-geos.js +8 -76
  62. package/src/helpers/addUIDs.ts +13 -2
  63. package/src/helpers/applyColorToLegend.ts +25 -1
  64. package/src/helpers/applyLegendToRow.ts +5 -3
  65. package/src/helpers/constants.ts +3 -15
  66. package/src/helpers/displayGeoName.ts +22 -4
  67. package/src/helpers/generateRuntimeFilters.ts +1 -1
  68. package/src/helpers/generateRuntimeLegend.ts +1 -3
  69. package/src/helpers/generateRuntimeLegendHash.ts +1 -1
  70. package/src/helpers/getCountriesPicked.ts +103 -0
  71. package/src/helpers/getMapContainerClasses.ts +7 -0
  72. package/src/helpers/getPatternForRow.ts +2 -5
  73. package/src/helpers/index.ts +2 -4
  74. package/src/helpers/isLegendItemDisabled.ts +2 -2
  75. package/src/helpers/resetLegendToggles.ts +1 -0
  76. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  77. package/src/helpers/tests/hashObj.test.ts +1 -1
  78. package/src/helpers/tests/titleCase.test.ts +76 -0
  79. package/src/helpers/titleCase.ts +13 -13
  80. package/src/helpers/toggleLegendActive.ts +76 -8
  81. package/src/helpers/urlDataHelpers.ts +1 -1
  82. package/src/hooks/useCountryZoom.tsx +241 -0
  83. package/src/hooks/useGeoClickHandler.ts +1 -1
  84. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  85. package/src/hooks/useResizeObserver.ts +8 -2
  86. package/src/hooks/useStateZoom.tsx +7 -4
  87. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  88. package/src/index.jsx +1 -0
  89. package/src/scss/editor-panel.scss +4 -440
  90. package/src/scss/main.scss +1 -1
  91. package/src/scss/map.scss +12 -15
  92. package/src/store/map.actions.ts +7 -7
  93. package/src/test/CdcMap.test.jsx +1 -1
  94. package/src/types/MapConfig.ts +32 -11
  95. package/src/types/MapContext.ts +6 -0
  96. package/src/types/runtimeLegend.ts +2 -1
  97. package/LICENSE +0 -201
  98. package/src/components/DataTable.tsx +0 -413
  99. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  100. package/src/components/MapControls.tsx +0 -44
  101. package/src/helpers/getUniqueValues.ts +0 -19
  102. package/src/helpers/hashObj.ts +0 -25
  103. package/src/hooks/useActiveElement.ts +0 -19
  104. package/src/hooks/useLegendSeparators.ts +0 -26
  105. package/src/scss/mixins.scss +0 -47
  106. package/src/types/Annotations.ts +0 -24
  107. /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
@@ -1,20 +1,7 @@
1
1
  export const SVG_WIDTH = 880
2
2
  export const SVG_HEIGHT = 500
3
- export const SVG_PADDING = 50
3
+ export const SVG_PADDING = 25
4
4
  export const SVG_VIEWBOX = `0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`
5
- export const HEADER_COLORS = [
6
- 'theme-blue',
7
- 'theme-purple',
8
- 'theme-brown',
9
- 'theme-teal',
10
- 'theme-pink',
11
- 'theme-orange',
12
- 'theme-slate',
13
- 'theme-indigo',
14
- 'theme-cyan',
15
- 'theme-green',
16
- 'theme-amber'
17
- ]
18
5
  export const MAX_ZOOM_LEVEL = 4
19
6
 
20
7
  export const SUPPORTED_DC_NAMES = [
@@ -43,9 +30,10 @@ export const GEOCODE_TYPES = {
43
30
 
44
31
  export const DEFAULT_MAP_BACKGROUND = '#DFE1E2'
45
32
 
33
+ export const DISABLED_MAP_COLOR = '#FFFFFF'
34
+
46
35
  // Component constants
47
36
  export const LOGO_MAX_WIDTH = '50px'
48
- export const STORYBOOK_PORT = 6006
49
37
 
50
38
  // CSV Parsing Configuration
51
39
  export const CSV_PARSE_CONFIG = {
@@ -4,10 +4,12 @@ import {
4
4
  supportedTerritories,
5
5
  supportedCountries,
6
6
  supportedCounties,
7
+ supportedCities,
7
8
  stateKeys,
8
9
  territoryKeys,
9
10
  countryKeys,
10
- countyKeys
11
+ countyKeys,
12
+ cityKeys
11
13
  } from '../data/supported-geos'
12
14
 
13
15
  /**
@@ -20,14 +22,17 @@ import {
20
22
  export const displayGeoName = (key: string, convertFipsCodes = true): string => {
21
23
  if (!convertFipsCodes) return key
22
24
  let value = key
25
+ let wasLookedUp = false
23
26
 
24
27
  // Map to first item in values array which is the preferred label
25
28
  if (stateKeys.includes(value)) {
26
29
  value = titleCase(supportedStates[key][0])
30
+ wasLookedUp = true
27
31
  }
28
32
 
29
33
  if (territoryKeys.includes(value)) {
30
34
  value = titleCase(supportedTerritories[key][0])
35
+ wasLookedUp = true
31
36
  if (value === 'U.s. Virgin Islands') {
32
37
  value = 'U.S. Virgin Islands'
33
38
  }
@@ -35,27 +40,40 @@ export const displayGeoName = (key: string, convertFipsCodes = true): string =>
35
40
 
36
41
  if (countryKeys.includes(value)) {
37
42
  value = titleCase(supportedCountries[key][0])
43
+ wasLookedUp = true
38
44
  }
39
45
 
40
46
  if (countyKeys.includes(value)) {
41
47
  value = titleCase(supportedCounties[key])
48
+ wasLookedUp = true
42
49
  }
43
50
 
51
+ // Check dictionary replacements before city lookup to handle special cases like DC
44
52
  const dict = {
45
53
  'Washington D.C.': 'District of Columbia',
46
54
  'WASHINGTON DC': 'District of Columbia',
47
55
  DC: 'District of Columbia',
48
56
  'WASHINGTON DC.': 'District of Columbia',
57
+ 'DISTRICT OF COLUMBIA': 'District of Columbia',
58
+ Dc: 'District of Columbia',
49
59
  Congo: 'Republic of the Congo'
50
60
  }
51
61
 
52
62
  if (Object.keys(dict).includes(value)) {
53
63
  value = dict[value]
64
+ wasLookedUp = true
54
65
  }
55
- // if you get here and it's 2 letters then DONT titleCase state abbreviations like "AL"
56
- if (value?.length === 2 || value === 'U.S. Virgin Islands') {
66
+
67
+ if (cityKeys.includes(value)) {
68
+ value = titleCase(String(value) || '')
69
+ wasLookedUp = true
70
+ }
71
+
72
+ // If value was looked up from our dictionaries and needs formatting, or if it's a 2-letter abbreviation, return as-is
73
+ if (value?.length === 2 || value === 'U.S. Virgin Islands' || wasLookedUp) {
57
74
  return value
58
75
  } else {
59
- return value
76
+ // Apply titleCase to unrecognized values (e.g., "DISTRICT OF COLUMBIA" -> "District of Columbia")
77
+ return titleCase(value)
60
78
  }
61
79
  }
@@ -1,4 +1,4 @@
1
- import { getUniqueValues } from './index'
1
+ import { getUniqueValues } from '@cdc/core/helpers/getUniqueValues'
2
2
  import { handleSorting } from '@cdc/core/components/Filters'
3
3
 
4
4
  export const generateRuntimeFilters = (state, hash, runtimeFilters) => {
@@ -4,11 +4,11 @@ import {
4
4
  addUIDs,
5
5
  applyColorToLegend,
6
6
  getGeoFillColor,
7
- hashObj,
8
7
  indexOfIgnoreType,
9
8
  setBinNumbers,
10
9
  sortSpecialClassesLast
11
10
  } from '.'
11
+ import { hashObj } from '@cdc/core/helpers/hashObj'
12
12
 
13
13
  import _ from 'lodash'
14
14
  import * as d3 from 'd3'
@@ -598,5 +598,3 @@ export const generateRuntimeLegend = (
598
598
  }
599
599
  }
600
600
  }
601
-
602
- export default generateRuntimeLegend
@@ -1,4 +1,4 @@
1
- import { hashObj } from './hashObj'
1
+ import { hashObj } from '@cdc/core/helpers/hashObj'
2
2
  import { MapConfig } from '../types/MapConfig'
3
3
 
4
4
  export const generateRuntimeLegendHash = (config: MapConfig, runtimeFilters) => {
@@ -0,0 +1,103 @@
1
+ import { supportedCountries } from '../data/supported-geos'
2
+ import type { MapConfig } from '../types/MapConfig'
3
+
4
+ export interface CountryPickedInfo {
5
+ iso: string
6
+ name: string
7
+ }
8
+
9
+ export const getCountriesPicked = (config: MapConfig): CountryPickedInfo[] => {
10
+ if (!config.general.countriesPicked || config.general.countriesPicked.length === 0) {
11
+ return []
12
+ }
13
+
14
+ return config.general.countriesPicked.map(country => {
15
+ // Validate that the ISO code exists in our supported countries
16
+ if (!supportedCountries[country.iso]) {
17
+ console.error(`Country ISO code "${country.iso}" not found in supported countries.`)
18
+ }
19
+
20
+ return {
21
+ iso: country.iso,
22
+ name: country.name
23
+ }
24
+ })
25
+ }
26
+
27
+ /**
28
+ * ISO codes that are in supported-geos.js but don't have geometries in world-topo.json
29
+ * These are filtered out to prevent users from selecting countries that won't render
30
+ */
31
+ const EXCLUDED_ISOS = new Set([
32
+ // US Territories (not in topology - grouped with USA or missing)
33
+ 'ASM',
34
+ 'GUM',
35
+ 'MNP',
36
+ 'VIR',
37
+ // Small territories/islands without separate geometries
38
+ 'ALA',
39
+ 'AIA',
40
+ 'AND',
41
+ 'ABW',
42
+ 'BES',
43
+ 'BMU',
44
+ 'BVT',
45
+ 'CXR',
46
+ 'CCK',
47
+ 'COK',
48
+ 'CUW',
49
+ 'FRO',
50
+ 'GGY',
51
+ 'HMD',
52
+ 'IMN',
53
+ 'JEY',
54
+ 'LIE',
55
+ 'MCO',
56
+ 'MSR',
57
+ 'NRU',
58
+ 'NIU',
59
+ 'NFK',
60
+ 'PCN',
61
+ 'SGS',
62
+ 'SJM',
63
+ 'TKL',
64
+ 'TCA',
65
+ 'TUV',
66
+ 'VAT',
67
+ 'WLF'
68
+ ])
69
+
70
+ /**
71
+ * Helper to get all supported countries formatted for dropdown options
72
+ * Filters to only valid ISO 3166-1 alpha-3 codes and removes countries without topology
73
+ */
74
+ export const getSupportedCountryOptions = () => {
75
+ return Object.keys(supportedCountries)
76
+ .filter(iso => /^[A-Z]{3}$/.test(iso)) // Only proper 3-letter ISO codes
77
+ .filter(iso => !EXCLUDED_ISOS.has(iso)) // Exclude countries without topology
78
+ .map(iso => ({
79
+ value: iso,
80
+ label: supportedCountries[iso][0] // Use the first (primary) name
81
+ }))
82
+ .sort((a, b) => a.label.localeCompare(b.label)) // Sort alphabetically by name
83
+ }
84
+
85
+ /**
86
+ * Helper to determine if the map should show only selected countries
87
+ * Returns true if countries are selected, false if showing all countries
88
+ */
89
+ export const isMultiCountryActive = (config: MapConfig): boolean => {
90
+ return Boolean(config.general.countriesPicked && config.general.countriesPicked.length > 0)
91
+ }
92
+
93
+ /**
94
+ * Helper to determine display mode for unselected countries
95
+ * Returns 'hidden' if hideUnselectedCountries is true, 'grayed' if false (default)
96
+ */
97
+ export const getUnselectedCountryDisplayMode = (config: MapConfig): 'hidden' | 'grayed' | 'normal' => {
98
+ if (!isMultiCountryActive(config)) {
99
+ return 'normal' // Show all countries normally when none are specifically selected
100
+ }
101
+
102
+ return config.general.hideUnselectedCountries ? 'hidden' : 'grayed'
103
+ }
@@ -1,4 +1,5 @@
1
1
  import { type MapConfig } from './../types/MapConfig'
2
+ import { isMultiCountryActive } from './getCountriesPicked'
2
3
 
3
4
  export const getMapContainerClasses = (state: MapConfig, modal) => {
4
5
  const { general } = state
@@ -19,5 +20,11 @@ export const getMapContainerClasses = (state: MapConfig, modal) => {
19
20
  if (general.type === 'navigation' && true === general.fullBorder) {
20
21
  mapContainerClasses.push('full-border')
21
22
  }
23
+
24
+ // Add multi-country class when multi-country mode is active
25
+ if (isMultiCountryActive(state)) {
26
+ mapContainerClasses.push('multi-country-selected')
27
+ }
28
+
22
29
  return mapContainerClasses
23
30
  }
@@ -1,6 +1,6 @@
1
1
  import { MapConfig } from '../types/MapConfig'
2
2
 
3
- export interface PatternInfo {
3
+ interface PatternInfo {
4
4
  pattern?: string
5
5
  dataKey: string
6
6
  size?: string
@@ -8,10 +8,7 @@ export interface PatternInfo {
8
8
  color?: string
9
9
  }
10
10
 
11
- export const getPatternForRow = (
12
- rowObj: Record<string, any>,
13
- config: MapConfig
14
- ): PatternInfo | null => {
11
+ export const getPatternForRow = (rowObj: Record<string, any>, config: MapConfig): PatternInfo | null => {
15
12
  if (!config.map?.patterns || !rowObj) {
16
13
  return null
17
14
  }
@@ -5,10 +5,8 @@ export { formatLegendLocation } from './formatLegendLocation'
5
5
  export { generateColorsArray } from '@cdc/core/helpers/generateColorsArray'
6
6
  export { generateRuntimeLegendHash } from './generateRuntimeLegendHash'
7
7
  export { getGeoStrokeColor, getGeoFillColor } from './colors'
8
- export { getUniqueValues } from './getUniqueValues'
9
8
  export { handleMapAriaLabels } from './handleMapAriaLabels'
10
9
  export { handleMapTabbing } from './handleMapTabbing'
11
- export { hashObj } from './hashObj'
12
10
  export { indexOfIgnoreType } from './indexOfIgnoreType'
13
11
  export { isLegendItemDisabled } from './isLegendItemDisabled'
14
12
  export { navigationHandler } from './navigationHandler'
@@ -24,7 +22,7 @@ export {
24
22
  SVG_WIDTH,
25
23
  SVG_PADDING,
26
24
  SVG_VIEWBOX,
27
- HEADER_COLORS,
28
25
  MAX_ZOOM_LEVEL,
29
- DEFAULT_MAP_BACKGROUND
26
+ DEFAULT_MAP_BACKGROUND,
27
+ DISABLED_MAP_COLOR
30
28
  } from './constants'
@@ -1,4 +1,4 @@
1
- import { hashObj } from './hashObj'
1
+ import { hashObj } from '@cdc/core/helpers/hashObj'
2
2
 
3
3
  export const isLegendItemDisabled = (
4
4
  dataForCheck: any,
@@ -12,5 +12,5 @@ export const isLegendItemDisabled = (
12
12
  if (!legendMemo.current.has(hash)) return false
13
13
  const idx = legendMemo.current.get(hash)
14
14
  const disabledIdx = config.legend.showSpecialClassesLast ? legendSpecialClassLastMemo.current.get(hash) ?? idx : idx
15
- return runtimeLegend.items[disabledIdx]?.disabled || false
15
+ return runtimeLegend.items[disabledIdx]?.disabled || runtimeLegend.items[disabledIdx]?.hidden || false
16
16
  }
@@ -5,6 +5,7 @@ export const resetLegendToggles = (runtimeLegend, dispatch) => {
5
5
 
6
6
  legendCopy.items.forEach(legendItem => {
7
7
  delete legendItem.disabled
8
+ delete legendItem.hidden
8
9
  })
9
10
  legendCopy.disabledAmt = 0
10
11
 
@@ -0,0 +1,359 @@
1
+ import { DataRow, MapConfig } from '../types/MapConfig'
2
+
3
+ /**
4
+ * Get unique values from a specific column in the data
5
+ * These values will become the tiles in small multiples view
6
+ *
7
+ * @param data - The full dataset
8
+ * @param tileColumn - The column name to extract unique values from
9
+ * @returns Array of unique values, sorted alphabetically, with null/undefined filtered out
10
+ */
11
+ export const getTileValues = (data: DataRow[], tileColumn: string): (string | number)[] => {
12
+ if (!data || !tileColumn) return []
13
+
14
+ const uniqueValues = Array.from(new Set(data.map(row => row[tileColumn])))
15
+ .filter(val => val != null && val !== '')
16
+ .sort()
17
+
18
+ return uniqueValues as (string | number)[]
19
+ }
20
+
21
+ /**
22
+ * Filter data for a specific tile based on the tile column and value
23
+ *
24
+ * @param allData - The complete dataset
25
+ * @param tileColumn - The column to filter by
26
+ * @param tileValue - The value to filter for
27
+ * @returns Filtered data containing only rows where column === value
28
+ */
29
+ export const getTileData = (allData: DataRow[], tileColumn: string, tileValue: string | number): DataRow[] => {
30
+ if (!allData || !tileColumn) return []
31
+
32
+ return allData.filter(row => row[tileColumn] === tileValue)
33
+ }
34
+
35
+ /**
36
+ * Get the display title for a tile
37
+ * Uses custom title if configured, otherwise returns the column value
38
+ *
39
+ * @param tileValue - The value from the tile column
40
+ * @param tileTitles - Object mapping values to custom titles
41
+ * @returns The display title for the tile
42
+ */
43
+ export const getTileDisplayTitle = (tileValue: string | number, tileTitles?: { [key: string]: string }): string => {
44
+ if (tileTitles && tileTitles[String(tileValue)]) {
45
+ return tileTitles[String(tileValue)]
46
+ }
47
+ return String(tileValue)
48
+ }
49
+
50
+ /**
51
+ * Apply tile ordering based on configuration
52
+ * Supports ascending, descending, and custom ordering
53
+ *
54
+ * @param tileValues - Array of tile values to order
55
+ * @param orderType - Type of ordering: 'asc', 'desc', or 'custom'
56
+ * @param customOrder - Custom order array (used when orderType is 'custom')
57
+ * @param tileTitles - Custom titles for display (used for sorting by title)
58
+ * @returns Ordered array of tile values
59
+ */
60
+ export const applyTileOrder = (
61
+ tileValues: (string | number)[],
62
+ orderType?: 'asc' | 'desc' | 'custom',
63
+ customOrder?: string[],
64
+ tileTitles?: { [key: string]: string }
65
+ ): (string | number)[] => {
66
+ if (!orderType || !tileValues.length) {
67
+ return tileValues
68
+ }
69
+
70
+ switch (orderType) {
71
+ case 'asc':
72
+ return [...tileValues].sort((a, b) => {
73
+ const titleA = getTileDisplayTitle(a, tileTitles).toLowerCase()
74
+ const titleB = getTileDisplayTitle(b, tileTitles).toLowerCase()
75
+ return titleA.localeCompare(titleB)
76
+ })
77
+
78
+ case 'desc':
79
+ return [...tileValues].sort((a, b) => {
80
+ const titleA = getTileDisplayTitle(a, tileTitles).toLowerCase()
81
+ const titleB = getTileDisplayTitle(b, tileTitles).toLowerCase()
82
+ return titleB.localeCompare(titleA)
83
+ })
84
+
85
+ case 'custom':
86
+ if (!customOrder || customOrder.length === 0) {
87
+ return tileValues
88
+ }
89
+
90
+ // Sort tiles based on their position in customOrder array
91
+ return [...tileValues].sort((a, b) => {
92
+ const keyA = String(a)
93
+ const keyB = String(b)
94
+
95
+ const orderA = customOrder.indexOf(keyA)
96
+ const orderB = customOrder.indexOf(keyB)
97
+
98
+ // Items not in customOrder go to the end
99
+ const finalOrderA = orderA === -1 ? 999999 : orderA
100
+ const finalOrderB = orderB === -1 ? 999999 : orderB
101
+
102
+ return finalOrderA - finalOrderB
103
+ })
104
+
105
+ default:
106
+ return tileValues
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get tile keys for editor/configuration purposes
112
+ * This is used in the editor to show available tiles for ordering/titling
113
+ *
114
+ * @param config - The map configuration
115
+ * @param data - The dataset
116
+ * @returns Array of tile keys (same as getTileValues but specifically for editor use)
117
+ */
118
+ export const getTileKeys = (config: MapConfig, data: DataRow[]): (string | number)[] => {
119
+ if (!config.smallMultiples?.tileColumn || !data) {
120
+ return []
121
+ }
122
+
123
+ return getTileValues(data, config.smallMultiples.tileColumn)
124
+ }
125
+
126
+ /**
127
+ * Pivot data from long format to wide format for DataTable display
128
+ * Transforms data so each unique tileColumn value becomes its own column
129
+ *
130
+ * Example:
131
+ * From: [{geo: "AL", value: 100, pathogen: "COVID"}, {geo: "AL", value: 50, pathogen: "Flu"}]
132
+ * To: [{geo: "AL", COVID: 100, Flu: 50}]
133
+ *
134
+ * @param data - Original data in long format
135
+ * @param tileColumn - Column to pivot on (e.g., "pathogen")
136
+ * @param valueColumn - Column containing values to pivot (e.g., "value")
137
+ * @param geoColumn - Geography column name (e.g., "geo")
138
+ * @param tileValues - Ordered array of tile values (determines column order)
139
+ * @returns Pivoted data in wide format
140
+ */
141
+ export const pivotDataForDataTable = (
142
+ data: DataRow[],
143
+ tileColumn: string,
144
+ valueColumn: string,
145
+ geoColumn: string,
146
+ tileValues: (string | number)[]
147
+ ): DataRow[] => {
148
+ if (!data || !tileColumn || !valueColumn || !geoColumn) return []
149
+
150
+ // Group data by geography
151
+ const geoGroups = new Map<string, DataRow[]>()
152
+
153
+ data.forEach(row => {
154
+ const geoKey = String(row[geoColumn])
155
+ if (!geoGroups.has(geoKey)) {
156
+ geoGroups.set(geoKey, [])
157
+ }
158
+ geoGroups.get(geoKey)!.push(row)
159
+ })
160
+
161
+ // Create pivoted rows
162
+ const pivotedData: DataRow[] = []
163
+
164
+ geoGroups.forEach((rows, geoKey) => {
165
+ const pivotedRow: DataRow = {
166
+ [geoColumn]: geoKey
167
+ }
168
+
169
+ // Copy non-value, non-tile columns from first row (they should be the same for all rows of this geo)
170
+ const firstRow = rows[0]
171
+ Object.keys(firstRow).forEach(key => {
172
+ if (key !== tileColumn && key !== valueColumn && key !== geoColumn) {
173
+ pivotedRow[key] = firstRow[key]
174
+ }
175
+ })
176
+
177
+ // Add a column for each tile value
178
+ rows.forEach(row => {
179
+ const tileValue = row[tileColumn]
180
+ if (tileValue != null && tileValue !== '') {
181
+ pivotedRow[String(tileValue)] = row[valueColumn]
182
+ }
183
+ })
184
+
185
+ pivotedData.push(pivotedRow)
186
+ })
187
+
188
+ return pivotedData
189
+ }
190
+
191
+ /**
192
+ * Pivot runtimeData from long format to wide format
193
+ * RuntimeData is an object keyed by UID, so we need to pivot the values within each UID
194
+ *
195
+ * @param runtimeData - Original runtimeData object keyed by UID
196
+ * @param tileColumn - Column to pivot on (e.g., "pathogen")
197
+ * @param valueColumn - Column containing values to pivot (e.g., "activity_level_label")
198
+ * @param geoColumn - Geography column name (e.g., "State")
199
+ * @param allData - Full dataset to find all rows for each geo
200
+ * @param tileValues - Ordered array of tile values
201
+ * @returns Pivoted runtimeData
202
+ */
203
+ export const pivotRuntimeDataForDataTable = (
204
+ runtimeData: { [uid: string]: any },
205
+ tileColumn: string,
206
+ valueColumn: string,
207
+ geoColumn: string,
208
+ allData: DataRow[],
209
+ tileValues: (string | number)[]
210
+ ): { [uid: string]: any } => {
211
+ if (!runtimeData || !tileColumn || !valueColumn || !geoColumn || !allData) return runtimeData
212
+
213
+ const pivotedRuntimeData: { [uid: string]: any } = {}
214
+
215
+ // For each UID in runtimeData
216
+ Object.keys(runtimeData).forEach(uid => {
217
+ const baseRow = runtimeData[uid]
218
+ const geoValue = baseRow[geoColumn]
219
+
220
+ // Find all rows in allData for this geo
221
+ const rowsForThisGeo = allData.filter(row => row[geoColumn] === geoValue)
222
+
223
+ // Create pivoted row starting with base row
224
+ const pivotedRow = { ...baseRow }
225
+
226
+ // Add a property for each tile value
227
+ rowsForThisGeo.forEach(row => {
228
+ const tileValue = row[tileColumn]
229
+ if (tileValue != null && tileValue !== '') {
230
+ pivotedRow[String(tileValue)] = row[valueColumn]
231
+ }
232
+ })
233
+
234
+ // Remove the original value column and tile column
235
+ delete pivotedRow[valueColumn]
236
+ delete pivotedRow[tileColumn]
237
+
238
+ pivotedRuntimeData[uid] = pivotedRow
239
+ })
240
+
241
+ return pivotedRuntimeData
242
+ }
243
+
244
+ /**
245
+ * Create column configurations for pivoted data table
246
+ * Generates one column config for each tile value, copying formatting from the original value column
247
+ * Preserves column order by inserting new columns where the value column was
248
+ *
249
+ * @param originalColumns - Original columns configuration
250
+ * @param valueColumnName - Name of the value column to clone config from
251
+ * @param tileColumnName - Name of the tile column to remove
252
+ * @param tileValues - Array of tile values (becomes new column names)
253
+ * @param tileTitles - Custom titles for columns
254
+ * @returns New columns configuration with geo column + one column per tile value
255
+ */
256
+ export const createPivotedColumns = (
257
+ originalColumns: any,
258
+ valueColumnName: string,
259
+ tileColumnName: string,
260
+ tileValues: (string | number)[],
261
+ tileTitles?: { [key: string]: string }
262
+ ): any => {
263
+ // Find the original value column config to clone its formatting
264
+ // Need to search by column.name, not by key
265
+ let valueColumnConfig = {}
266
+ let valueColumnKey = null
267
+
268
+ Object.keys(originalColumns).forEach(key => {
269
+ if (originalColumns[key].name === valueColumnName) {
270
+ valueColumnConfig = originalColumns[key]
271
+ valueColumnKey = key
272
+ }
273
+ })
274
+
275
+ // Create new columns object preserving order
276
+ const newColumns = {}
277
+
278
+ // Iterate through original columns
279
+ Object.keys(originalColumns).forEach(key => {
280
+ const column = originalColumns[key]
281
+
282
+ // Check if this column's name matches the value column
283
+ if (column.name === valueColumnName) {
284
+ // Replace value column with pivoted columns
285
+ tileValues.forEach(tileValue => {
286
+ const columnKey = String(tileValue)
287
+ newColumns[columnKey] = {
288
+ ...valueColumnConfig,
289
+ name: columnKey,
290
+ label: getTileDisplayTitle(tileValue, tileTitles),
291
+ dataTable: true
292
+ }
293
+ })
294
+ } else if (column.name === tileColumnName) {
295
+ // Skip tile column - don't add it to new columns
296
+ return
297
+ } else {
298
+ // Keep all other columns
299
+ newColumns[key] = originalColumns[key]
300
+ }
301
+ })
302
+
303
+ return newColumns
304
+ }
305
+
306
+ /**
307
+ * Prepare data table props for small multiples display
308
+ * If small multiples is enabled, pivots data and columns. Otherwise returns originals.
309
+ *
310
+ * @param config - Map configuration
311
+ * @param columns - Original columns configuration
312
+ * @param runtimeData - Original runtime data
313
+ * @returns Object with modified config, columns, and runtimeData (or originals if not small multiples)
314
+ */
315
+ export const prepareSmallMultiplesDataTable = (
316
+ config: MapConfig,
317
+ columns: any,
318
+ runtimeData: any
319
+ ): { config: MapConfig; columns: any; runtimeData: any } => {
320
+ const { tileColumn, tileOrderType, tileOrder, tileTitles } = config.smallMultiples
321
+ const valueColumn = config.columns.primary?.name
322
+ const geoColumn = config.columns.geo?.name
323
+
324
+ // If required columns aren't configured, return originals
325
+ if (!valueColumn || !geoColumn) {
326
+ return { config, columns, runtimeData }
327
+ }
328
+
329
+ // Get ordered tile values
330
+ const rawTileValues = getTileValues(config.data, tileColumn)
331
+ const orderedTileValues = applyTileOrder(rawTileValues, tileOrderType, tileOrder, tileTitles)
332
+
333
+ // Pivot data
334
+ const pivotedData = pivotDataForDataTable(config.data, tileColumn, valueColumn, geoColumn, orderedTileValues)
335
+
336
+ // Pivot runtimeData
337
+ const pivotedRuntimeData = pivotRuntimeDataForDataTable(
338
+ runtimeData,
339
+ tileColumn,
340
+ valueColumn,
341
+ geoColumn,
342
+ config.data,
343
+ orderedTileValues
344
+ )
345
+
346
+ // Create pivoted columns
347
+ const pivotedColumns = createPivotedColumns(columns, valueColumn, tileColumn, orderedTileValues, tileTitles)
348
+
349
+ // Return modified config with pivoted data and columns
350
+ return {
351
+ config: {
352
+ ...config,
353
+ data: pivotedData,
354
+ columns: pivotedColumns
355
+ },
356
+ columns: pivotedColumns,
357
+ runtimeData: pivotedRuntimeData
358
+ }
359
+ }
@@ -1,4 +1,4 @@
1
- import { hashObj } from '../hashObj'
1
+ import { hashObj } from '@cdc/core/helpers/hashObj'
2
2
 
3
3
  describe('hashObj', () => {
4
4
  it('should return a hash value for a given object', () => {