@cdc/map 4.26.2 → 4.26.3

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/dist/cdcmap-vr9HZwRt.es.js +6 -0
  3. package/dist/cdcmap.js +26781 -24615
  4. package/examples/private/annotation-bug.json +642 -0
  5. package/package.json +3 -3
  6. package/src/CdcMap.tsx +3 -14
  7. package/src/CdcMapComponent.tsx +214 -159
  8. package/src/_stories/CdcMap.Defaults.stories.tsx +76 -0
  9. package/src/_stories/CdcMap.Editor.stories.tsx +187 -14
  10. package/src/_stories/CdcMap.stories.tsx +11 -1
  11. package/src/_stories/Map.HTMLInDataTable.stories.tsx +385 -0
  12. package/src/_stories/_mock/multi-state-show-unselected.json +82 -0
  13. package/src/cdcMapComponent.styles.css +2 -2
  14. package/src/components/Annotation/Annotation.Draggable.styles.css +4 -4
  15. package/src/components/Annotation/AnnotationDropdown.styles.css +1 -1
  16. package/src/components/Annotation/AnnotationList.styles.css +13 -13
  17. package/src/components/EditorPanel/components/EditorPanel.tsx +426 -58
  18. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings-style.css +1 -1
  19. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +5 -2
  20. package/src/components/EditorPanel/components/editorPanel.styles.css +34 -24
  21. package/src/components/Legend/components/Legend.tsx +9 -4
  22. package/src/components/Legend/components/LegendGroup/legend.group.css +5 -5
  23. package/src/components/Legend/components/index.scss +2 -3
  24. package/src/components/NavigationMenu.tsx +2 -1
  25. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  26. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +32 -17
  27. package/src/components/UsaMap/components/TerritoriesSection.tsx +3 -2
  28. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +13 -8
  29. package/src/components/UsaMap/components/UsaMap.County.tsx +410 -183
  30. package/src/components/UsaMap/components/UsaMap.Region.styles.css +1 -1
  31. package/src/components/UsaMap/components/UsaMap.SingleState.styles.css +2 -2
  32. package/src/components/UsaMap/components/UsaMap.State.tsx +13 -8
  33. package/src/components/WorldMap/WorldMap.tsx +10 -13
  34. package/src/components/WorldMap/data/world-topo-updated.json +1 -0
  35. package/src/components/WorldMap/data/world-topo.json +1 -1
  36. package/src/components/WorldMap/worldMap.styles.css +1 -1
  37. package/src/components/ZoomControls.tsx +49 -18
  38. package/src/components/zoomControls.styles.css +27 -11
  39. package/src/data/initial-state.js +14 -5
  40. package/src/data/legacy-defaults.ts +8 -0
  41. package/src/data/supported-geos.js +19 -0
  42. package/src/helpers/colors.ts +2 -1
  43. package/src/helpers/dataTableHelpers.ts +56 -0
  44. package/src/helpers/displayGeoName.ts +19 -11
  45. package/src/helpers/getMapContainerClasses.ts +8 -2
  46. package/src/helpers/getMatchingPatternForRow.ts +67 -0
  47. package/src/helpers/getPatternForRow.ts +11 -18
  48. package/src/helpers/tests/dataTableHelpers.test.ts +78 -0
  49. package/src/helpers/tests/displayGeoName.test.ts +17 -0
  50. package/src/helpers/tests/getMatchingPatternForRow.test.ts +150 -0
  51. package/src/helpers/tests/getPatternForRow.test.ts +140 -2
  52. package/src/helpers/urlDataHelpers.ts +7 -1
  53. package/src/hooks/useResizeObserver.ts +36 -22
  54. package/src/hooks/useTooltip.test.tsx +64 -0
  55. package/src/hooks/useTooltip.ts +28 -8
  56. package/src/scss/editor-panel.scss +1 -1
  57. package/src/scss/main.scss +140 -6
  58. package/src/scss/map.scss +9 -4
  59. package/src/store/map.actions.ts +2 -0
  60. package/src/store/map.reducer.ts +4 -0
  61. package/src/test/CdcMap.test.jsx +2 -2
  62. package/src/types/MapConfig.ts +22 -4
  63. package/src/types/MapContext.ts +3 -1
  64. package/dist/cdcmap-Cf9_fbQf.es.js +0 -6
  65. package/src/helpers/componentHelpers.ts +0 -8
@@ -4,8 +4,8 @@
4
4
  }
5
5
  .geography-container {
6
6
  cursor: move;
7
- position: relative;
8
7
  flex-grow: 1;
8
+ position: relative;
9
9
  width: 100%;
10
10
  .geo-point {
11
11
  transition: 0.3s all;
@@ -13,27 +13,58 @@ type ZoomControlsProps = {
13
13
  const ZoomControls: React.FC<ZoomControlsProps> = ({ handleZoomIn, handleZoomOut, handleZoomReset }) => {
14
14
  const { config, setRuntimeData, position } = useContext<MapContext>(ConfigContext)
15
15
  if (!config.general.allowMapZoom) return
16
+
17
+ const isUsGeocodeMap = config.general.type === 'us-geocode'
18
+ const shouldShowUsGeocodeReset = isUsGeocodeMap && position.zoom > 1
19
+ const shouldShowBottomReset = config.general.geoType === 'single-state' || config.general.type === 'bubble'
20
+
21
+ if (!isUsGeocodeMap) {
22
+ return (
23
+ <div className='zoom-controls' data-html2canvas-ignore='true'>
24
+ <button onClick={() => handleZoomIn(position)} aria-label='Zoom In'>
25
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
26
+ <line x1='12' y1='5' x2='12' y2='19' />
27
+ <line x1='5' y1='12' x2='19' y2='12' />
28
+ </svg>
29
+ </button>
30
+ <button onClick={() => handleZoomOut(position)} aria-label='Zoom Out'>
31
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
32
+ <line x1='5' y1='12' x2='19' y2='12' />
33
+ </svg>
34
+ </button>
35
+ {shouldShowBottomReset && (
36
+ <button onClick={() => handleZoomReset(setRuntimeData)} className='reset' aria-label='Reset Zoom'>
37
+ Reset Zoom
38
+ </button>
39
+ )}
40
+ </div>
41
+ )
42
+ }
43
+
16
44
  return (
17
- <div className='zoom-controls' data-html2canvas-ignore='true'>
18
- <button onClick={() => handleZoomIn(position)} aria-label='Zoom In'>
19
- <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
20
- <line x1='12' y1='5' x2='12' y2='19' />
21
- <line x1='5' y1='12' x2='19' y2='12' />
22
- </svg>
23
- </button>
24
- <button onClick={() => handleZoomOut(position)} aria-label='Zoom Out'>
25
- <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
26
- <line x1='5' y1='12' x2='19' y2='12' />
27
- </svg>
28
- </button>
29
- {(config.general.type === 'world-geocode' ||
30
- config.general.geoType === 'single-state' ||
31
- config.general.type === 'bubble') && (
32
- <button onClick={() => handleZoomReset(setRuntimeData)} className='reset' aria-label='Reset Zoom'>
33
- Reset Zoom
45
+ <>
46
+ <div className='zoom-controls' data-html2canvas-ignore='true'>
47
+ <button onClick={() => handleZoomIn(position)} aria-label='Zoom In'>
48
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
49
+ <line x1='12' y1='5' x2='12' y2='19' />
50
+ <line x1='5' y1='12' x2='19' y2='12' />
51
+ </svg>
52
+ </button>
53
+ <button onClick={() => handleZoomOut(position)} aria-label='Zoom Out'>
54
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
55
+ <line x1='5' y1='12' x2='19' y2='12' />
56
+ </svg>
34
57
  </button>
58
+ </div>
59
+
60
+ {shouldShowUsGeocodeReset && (
61
+ <div className='zoom-controls zoom-controls--top-right' data-html2canvas-ignore='true'>
62
+ <button onClick={() => handleZoomReset(setRuntimeData)} className='reset' aria-label='Reset Zoom'>
63
+ Reset Zoom
64
+ </button>
65
+ </div>
35
66
  )}
36
- </div>
67
+ </>
37
68
  )
38
69
  }
39
70
 
@@ -1,14 +1,14 @@
1
1
  .zoom-controls {
2
- display: flex;
3
- position: absolute;
4
2
  bottom: 2em;
3
+ display: flex;
5
4
  left: 1em;
5
+ position: absolute;
6
6
  z-index: 4;
7
7
  > button.reset {
8
- margin-left: 5px;
9
8
  background: rgba(0, 0, 0, 0.65);
10
- transition: 0.2s all;
11
9
  color: #fff;
10
+ margin-left: 5px;
11
+ transition: 0.2s all;
12
12
  &:hover {
13
13
  background: rgba(0, 0, 0, 0.8);
14
14
  transition: 0.2s all;
@@ -21,17 +21,17 @@
21
21
  }
22
22
  }
23
23
  > button:not(.reset) {
24
- display: flex;
25
24
  align-items: center;
25
+ background: rgba(0, 0, 0, 0.65);
26
+ border: 0;
27
+ border-radius: 100%;
28
+ color: #fff;
29
+ display: flex;
30
+ height: 1.75em;
26
31
  justify-content: center;
27
32
  padding: 0.2em;
28
- height: 1.75em;
29
- width: 1.75em;
30
- background: rgba(0, 0, 0, 0.65);
31
33
  transition: 0.2s all;
32
- color: #fff;
33
- border-radius: 100%;
34
- border: 0;
34
+ width: 1.75em;
35
35
  &:hover {
36
36
  background: rgba(0, 0, 0, 0.8);
37
37
  transition: 0.2s all;
@@ -51,3 +51,19 @@
51
51
  margin-right: 0.25em;
52
52
  }
53
53
  }
54
+
55
+ .zoom-controls--top-right {
56
+ bottom: auto;
57
+ left: auto;
58
+ right: 1em;
59
+ top: 1em;
60
+ }
61
+
62
+ .zoom-controls--top-right > button.reset {
63
+ background: #005eaa;
64
+ }
65
+
66
+ .zoom-controls--top-right > button.reset:hover,
67
+ .zoom-controls--top-right > button.reset:focus {
68
+ background: #004b88;
69
+ }
@@ -41,6 +41,7 @@ const createInitialState = () => {
41
41
  allowMapZoom: true,
42
42
  hideGeoColumnInTooltip: false,
43
43
  hidePrimaryColumnInTooltip: false,
44
+ hideUnselectedStates: true,
44
45
  statesPicked: []
45
46
  },
46
47
  type: 'map',
@@ -49,7 +50,8 @@ const createInitialState = () => {
49
50
  name: 'FIPS Codes',
50
51
  label: 'Location',
51
52
  tooltip: false,
52
- dataTable: true
53
+ dataTable: true,
54
+ displayColumn: ''
53
55
  },
54
56
  primary: {
55
57
  dataTable: true,
@@ -76,14 +78,14 @@ const createInitialState = () => {
76
78
  showSpecialClassesLast: false,
77
79
  dynamicDescription: false,
78
80
  type: 'equalnumber',
79
- numberOfItems: 3,
80
- position: 'side',
81
+ numberOfItems: 5,
82
+ position: 'top',
81
83
  title: '',
82
- style: 'circles',
84
+ style: 'gradient',
83
85
  subStyle: 'linear blocks',
84
86
  tickRotation: '',
85
87
  singleColumnLegend: false,
86
- hideBorder: false,
88
+ hideBorder: true,
87
89
  groupBy: ''
88
90
  },
89
91
  filters: [],
@@ -114,6 +116,13 @@ const createInitialState = () => {
114
116
  editorErrorMessage: []
115
117
  },
116
118
  visual: {
119
+ border: false,
120
+ borderColorTheme: false,
121
+ accent: false,
122
+ background: false,
123
+ hideBackgroundColor: false,
124
+ tp5Treatment: false,
125
+ tp5Background: false,
117
126
  minBubbleSize: 1,
118
127
  maxBubbleSize: 20,
119
128
  extraBubbleBorder: false,
@@ -0,0 +1,8 @@
1
+ // Preserves the OLD default values for properties changed in initial-state.js.
2
+ // When the backfill loop fills a missing property, it uses these values instead
3
+ // of the current defaults so that existing configs aren't visually affected.
4
+ //
5
+ // See backfillDefaults() in @cdc/core for the shared fill logic.
6
+ export const LEGACY_MAP_DEFAULTS: Record<string, Record<string, unknown>> = {
7
+ legend: { style: 'circles', position: 'side', numberOfItems: 3, hideBorder: false }
8
+ }
@@ -493,7 +493,9 @@ export const supportedCountries = {
493
493
  SOM: ['Somalia', 'Somaliland'],
494
494
  ZAF: ['South Africa'],
495
495
  SGS: ['South Georgia and the South Sandwich Islands', 'S. Geo. and the Is.'],
496
+ SSD: ['South Sudan', 'S. Sudan'],
496
497
  SDS: ['South Sudan', 'S. Sudan'],
498
+ XSV: ['Svalbard'],
497
499
  ESP: ['Spain'],
498
500
  LKA: ['Sri Lanka'],
499
501
  SDN: ['Sudan'],
@@ -531,11 +533,28 @@ export const supportedCountries = {
531
533
  VIR: ['Virgin Islands (U.S.)', 'U.S. Virgin Is.'],
532
534
  WLF: ['Wallis and Futuna', 'Wallis and Futuna Is.'],
533
535
  KOS: ['Kosovo'],
536
+ XKS: ['Kosovo'],
534
537
  SAH: ['Western Sahara', 'W. Sahara'],
535
538
  YEM: ['Yemen'],
536
539
  ZMB: ['Zambia'],
537
540
  ZWE: ['Zimbabwe'],
538
541
  IOT: ['British Indian Ocean Territory'],
542
+ CPT: ['Clipperton Island'],
543
+ XAC: ['Ashmore and Cartier Islands'],
544
+ XBK: ['Baker Island'],
545
+ XCS: ['Coral Sea Islands'],
546
+ XHO: ['Howland Island'],
547
+ XJA: ['Johnston Atoll'],
548
+ XJM: ['Jan Mayen'],
549
+ XJV: ['Jarvis Island'],
550
+ XKR: ['Kingman Reef'],
551
+ XMW: ['Midway Islands'],
552
+ XNV: ['Navassa Island'],
553
+ XPL: ['Palmyra Atoll'],
554
+ XPR: ['Paracel Islands'],
555
+ XQZ: ['Akrotiri'],
556
+ XSP: ['Spratly Islands'],
557
+ XWK: ['Wake Island'],
539
558
  Alaska: ['Alaska'],
540
559
  Hawaii: ['Hawaii'],
541
560
  Sardinia: ['Sardinia'],
@@ -11,7 +11,8 @@ import { MapConfig } from '../types/MapConfig'
11
11
  export const getGeoStrokeColor = (config: MapConfig) => {
12
12
  const bodyStyles = getComputedStyle(document.body)
13
13
  if (config.general.geoBorderColor === 'darkGray') {
14
- return bodyStyles.getPropertyValue('--cool-gray-90')
14
+ const isCountyMap = config.general.geoType === 'us-county'
15
+ return bodyStyles.getPropertyValue(isCountyMap ? '--colors-gray-cool-70' : '--cool-gray-90')
15
16
  } else {
16
17
  return bodyStyles.getPropertyValue('--white')
17
18
  }
@@ -1,6 +1,62 @@
1
+ import {
2
+ stateFipsToTwoDigit as stateFipsToAbbreviation,
3
+ supportedStatesFipsCodes as supportedStateCodes
4
+ } from '../data/supported-geos'
5
+
1
6
  /**
2
7
  * Determines if the data table should be shown based on current state
3
8
  */
4
9
  export const shouldShowDataTable = (config: any, table: any, general: any, loading: boolean): boolean => {
5
10
  return !config?.runtime?.editorErrorMessage.length && table.forceDisplay && general.type !== 'navigation' && !loading
6
11
  }
12
+
13
+ /**
14
+ * Filters county runtime data to a selected state code for data table display.
15
+ * Keeps the original non-enumerable fromHash metadata when present.
16
+ */
17
+ export const filterCountyTableRuntimeDataByStateCode = (runtimeData: any, stateCode: string, config?: any) => {
18
+ if (!runtimeData || runtimeData.init || !stateCode) return runtimeData
19
+
20
+ const filtered = {}
21
+ const stateName = supportedStateCodes[stateCode]
22
+ const stateAbbreviation = stateFipsToAbbreviation[stateCode]
23
+ const normalizedSelectedStateCode = String(stateCode).replace(/^0+/, '')
24
+ const paddedSelectedStateCode = normalizedSelectedStateCode.padStart(2, '0')
25
+ const stateColumnNames = Object.values(config?.columns || {})
26
+ .map((column: any) => column?.name)
27
+ .filter((name: string) => !!name && /(state|territory|fips)/i.test(name))
28
+
29
+ if (runtimeData.fromHash !== undefined) {
30
+ Object.defineProperty(filtered, 'fromHash', {
31
+ value: runtimeData.fromHash
32
+ })
33
+ }
34
+
35
+ Object.keys(runtimeData).forEach(uid => {
36
+ const row = runtimeData[uid]
37
+ const uidPrefix = String(uid).slice(0, 2)
38
+ const normalizedUidPrefix = uidPrefix.startsWith('0') ? uidPrefix.slice(1) : uidPrefix
39
+ const matchesUidPrefix =
40
+ uidPrefix === paddedSelectedStateCode || normalizedUidPrefix === normalizedSelectedStateCode
41
+ const matchesStateColumn = stateColumnNames.some((columnName: string) => {
42
+ const rawValue = row?.[columnName]
43
+ if (rawValue === undefined || rawValue === null) return false
44
+
45
+ const value = String(rawValue).trim()
46
+ const normalizedValueStateCode = value.replace(/^0+/, '')
47
+
48
+ return (
49
+ (stateName && value.toLowerCase() === String(stateName).toLowerCase()) ||
50
+ (stateAbbreviation && value.toUpperCase() === String(stateAbbreviation).toUpperCase()) ||
51
+ value === stateCode ||
52
+ normalizedValueStateCode === normalizedSelectedStateCode
53
+ )
54
+ })
55
+
56
+ if (matchesUidPrefix || matchesStateColumn) {
57
+ filtered[uid] = runtimeData[uid]
58
+ }
59
+ })
60
+
61
+ return filtered
62
+ }
@@ -16,30 +16,38 @@ import {
16
16
  * Converts a geographic key to its display name.
17
17
  *
18
18
  * @param {string} key - The geographic key to convert.
19
- * @param {boolean} [convertFipsCodes=true] - Whether to convert FIPS codes.
19
+ * @param {string} [displayOverride] - If provided, returns this value immediately (used for translated/alternate display names).
20
20
  * @returns {string} - The display name for the geographic key.
21
21
  */
22
- export const displayGeoName = (key: string, convertFipsCodes = true): string => {
23
- if (!convertFipsCodes) return key
24
- let value = key
22
+ export const displayGeoName = (key: string, displayOverride?: string): string => {
23
+ const rawKey = String(key || '')
24
+ const trimmedOverride = typeof displayOverride === 'string' ? displayOverride.trim() : ''
25
+ const normalizedKey = rawKey.toUpperCase()
26
+ const normalizedOverride = trimmedOverride.toUpperCase()
27
+
28
+ if (trimmedOverride && normalizedOverride !== normalizedKey) {
29
+ return trimmedOverride
30
+ }
31
+
32
+ let value = rawKey
25
33
  let wasLookedUp = false
26
34
 
27
35
  // Map to first item in values array which is the preferred label
28
- if (stateKeys.includes(value)) {
29
- value = titleCase(supportedStates[key][0])
36
+ if (stateKeys.includes(normalizedKey)) {
37
+ value = titleCase(supportedStates[normalizedKey][0])
30
38
  wasLookedUp = true
31
39
  }
32
40
 
33
- if (territoryKeys.includes(value)) {
34
- value = titleCase(supportedTerritories[key][0])
41
+ if (territoryKeys.includes(normalizedKey)) {
42
+ value = titleCase(supportedTerritories[normalizedKey][0])
35
43
  wasLookedUp = true
36
44
  if (value === 'U.s. Virgin Islands') {
37
45
  value = 'U.S. Virgin Islands'
38
46
  }
39
47
  }
40
48
 
41
- if (countryKeys.includes(value)) {
42
- value = titleCase(supportedCountries[key][0])
49
+ if (countryKeys.includes(normalizedKey)) {
50
+ value = titleCase(supportedCountries[normalizedKey][0])
43
51
  wasLookedUp = true
44
52
  }
45
53
 
@@ -64,7 +72,7 @@ export const displayGeoName = (key: string, convertFipsCodes = true): string =>
64
72
  wasLookedUp = true
65
73
  }
66
74
 
67
- if (cityKeys.includes(value)) {
75
+ if (cityKeys.includes(normalizedKey)) {
68
76
  value = titleCase(String(value) || '')
69
77
  wasLookedUp = true
70
78
  }
@@ -1,12 +1,18 @@
1
1
  import { type MapConfig } from './../types/MapConfig'
2
2
  import { isMultiCountryActive } from './getCountriesPicked'
3
+ import { isBelowBreakpoint } from '@cdc/core/helpers/viewports'
3
4
 
4
- export const getMapContainerClasses = (state: MapConfig, modal) => {
5
+ export const getMapContainerClasses = (state: MapConfig, modal, currentViewport?: string) => {
5
6
  const { general } = state
7
+ const legendWrapping =
8
+ (state.legend?.position === 'left' || state.legend?.position === 'right') &&
9
+ currentViewport &&
10
+ (currentViewport === 'md' || isBelowBreakpoint('md', currentViewport))
6
11
 
7
12
  let mapContainerClasses = [
8
13
  'map-container',
9
- state.legend?.position,
14
+ 'visualization-container',
15
+ legendWrapping ? 'legend-wrapped-bottom' : state.legend?.position,
10
16
  state.general.type,
11
17
  state.general.geoType,
12
18
  'outline-none',
@@ -0,0 +1,67 @@
1
+ import { PatternSelection } from '../types/MapConfig'
2
+ import { patternValuesMatch } from './patternMatching'
3
+
4
+ export type MatchedPattern = {
5
+ pattern: PatternSelection
6
+ patternIndex: number
7
+ matchedDataKey: string
8
+ }
9
+
10
+ const hasPatternValue = (value: unknown): boolean => String(value ?? '').trim() !== ''
11
+
12
+ export const getMatchingPatternForRow = (
13
+ rowObj: Record<string, any> | null | undefined,
14
+ patterns: PatternSelection[] | null | undefined
15
+ ): MatchedPattern | null => {
16
+ if (!rowObj || !Array.isArray(patterns) || patterns.length === 0) {
17
+ return null
18
+ }
19
+
20
+ // First pass: specific key matches always win over broad matches.
21
+ // If multiple specific patterns match, keep the last configured one to
22
+ // preserve prior "last overlay wins" map behavior.
23
+ let lastSpecificMatch: MatchedPattern | null = null
24
+ for (let i = 0; i < patterns.length; i++) {
25
+ const pattern = patterns[i]
26
+ const dataKey = pattern?.dataKey ?? ''
27
+
28
+ if (String(dataKey).trim() === '' || !hasPatternValue(pattern?.dataValue)) {
29
+ continue
30
+ }
31
+
32
+ if (patternValuesMatch(pattern.dataValue, rowObj[dataKey])) {
33
+ lastSpecificMatch = {
34
+ pattern,
35
+ patternIndex: i,
36
+ matchedDataKey: dataKey
37
+ }
38
+ }
39
+ }
40
+ if (lastSpecificMatch) {
41
+ return lastSpecificMatch
42
+ }
43
+
44
+ // Second pass: broad matches (blank dataKey) compare against all row values.
45
+ // If multiple broad patterns match, keep the last configured one.
46
+ const rowEntries = Object.entries(rowObj)
47
+ let lastBroadMatch: MatchedPattern | null = null
48
+ for (let i = 0; i < patterns.length; i++) {
49
+ const pattern = patterns[i]
50
+
51
+ if (String(pattern?.dataKey ?? '').trim() !== '' || !hasPatternValue(pattern?.dataValue)) {
52
+ continue
53
+ }
54
+
55
+ for (const [rowKey, rowValue] of rowEntries) {
56
+ if (patternValuesMatch(pattern.dataValue, rowValue)) {
57
+ lastBroadMatch = {
58
+ pattern,
59
+ patternIndex: i,
60
+ matchedDataKey: rowKey
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return lastBroadMatch
67
+ }
@@ -1,5 +1,5 @@
1
1
  import { MapConfig } from '../types/MapConfig'
2
- import { patternValuesMatch } from './patternMatching'
2
+ import { getMatchingPatternForRow } from './getMatchingPatternForRow'
3
3
 
4
4
  interface PatternInfo {
5
5
  pattern?: string
@@ -10,25 +10,18 @@ interface PatternInfo {
10
10
  }
11
11
 
12
12
  export const getPatternForRow = (rowObj: Record<string, any>, config: MapConfig): PatternInfo | null => {
13
- if (!config.map?.patterns || !rowObj) {
13
+ const matchedPattern = getMatchingPatternForRow(rowObj, config.map?.patterns)
14
+
15
+ if (!matchedPattern) {
14
16
  return null
15
17
  }
16
18
 
17
- // Find a pattern that matches this row's data
18
- for (let i = 0; i < config.map.patterns.length; i++) {
19
- const patternData = config.map.patterns[i]
20
- const hasMatchingValues = patternValuesMatch(patternData.dataValue, rowObj[patternData.dataKey])
21
-
22
- if (hasMatchingValues) {
23
- return {
24
- pattern: patternData.pattern,
25
- dataKey: patternData.dataKey,
26
- size: patternData.size,
27
- patternIndex: i,
28
- color: patternData.color
29
- }
30
- }
19
+ return {
20
+ pattern: matchedPattern.pattern.pattern,
21
+ // Broad matches resolve to the row key that matched, so IDs/classes stay stable.
22
+ dataKey: matchedPattern.matchedDataKey,
23
+ size: matchedPattern.pattern.size,
24
+ patternIndex: matchedPattern.patternIndex,
25
+ color: matchedPattern.pattern.color
31
26
  }
32
-
33
- return null
34
27
  }
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterCountyTableRuntimeDataByStateCode } from '../dataTableHelpers'
3
+
4
+ describe('filterCountyTableRuntimeDataByStateCode', () => {
5
+ it('filters county rows by selected state fips prefix', () => {
6
+ const runtimeData = {
7
+ '06001': { uid: '06001', value: 1 },
8
+ '06013': { uid: '06013', value: 2 },
9
+ '12001': { uid: '12001', value: 3 }
10
+ }
11
+
12
+ const filtered = filterCountyTableRuntimeDataByStateCode(runtimeData, '06')
13
+
14
+ expect(Object.keys(filtered)).toEqual(['06001', '06013'])
15
+ expect(filtered['06001'].value).toBe(1)
16
+ expect(filtered['12001']).toBeUndefined()
17
+ })
18
+
19
+ it('filters county rows when state fips is provided without leading zero', () => {
20
+ const runtimeData = {
21
+ '06001': { uid: '06001', value: 1 },
22
+ '06013': { uid: '06013', value: 2 },
23
+ '12001': { uid: '12001', value: 3 }
24
+ }
25
+
26
+ const filtered = filterCountyTableRuntimeDataByStateCode(runtimeData, '6')
27
+
28
+ expect(Object.keys(filtered)).toEqual(['06001', '06013'])
29
+ expect(filtered['06001'].value).toBe(1)
30
+ expect(filtered['12001']).toBeUndefined()
31
+ })
32
+
33
+ it('preserves non-enumerable fromHash metadata', () => {
34
+ const runtimeData = {
35
+ '06001': { uid: '06001', value: 1 },
36
+ '12001': { uid: '12001', value: 2 }
37
+ }
38
+
39
+ Object.defineProperty(runtimeData, 'fromHash', {
40
+ value: 12345,
41
+ enumerable: false
42
+ })
43
+
44
+ const filtered = filterCountyTableRuntimeDataByStateCode(runtimeData, '06')
45
+
46
+ expect(filtered.fromHash).toBe(12345)
47
+ expect(Object.keys(filtered)).toEqual(['06001'])
48
+ })
49
+
50
+ it('filters us-geocode rows by configured state column when uid is not county fips', () => {
51
+ const runtimeData = {
52
+ 'ID:2472': { uid: 'ID:2472', 'State/Territory': 'Alabama' },
53
+ 'ID:1010': { uid: 'ID:1010', 'State/Territory': 'California' },
54
+ 'ID:2020': { uid: 'ID:2020', 'State/Territory': 'California' }
55
+ }
56
+
57
+ const config = {
58
+ columns: {
59
+ additionalColumn1: { name: 'State/Territory' }
60
+ }
61
+ }
62
+
63
+ const filtered = filterCountyTableRuntimeDataByStateCode(runtimeData, '06', config)
64
+
65
+ expect(Object.keys(filtered)).toEqual(['ID:1010', 'ID:2020'])
66
+ expect(filtered['ID:2472']).toBeUndefined()
67
+ })
68
+
69
+ it('returns original runtime data when state fips is empty', () => {
70
+ const runtimeData = {
71
+ '06001': { uid: '06001', value: 1 }
72
+ }
73
+
74
+ const filtered = filterCountyTableRuntimeDataByStateCode(runtimeData, '')
75
+
76
+ expect(filtered).toBe(runtimeData)
77
+ })
78
+ })
@@ -0,0 +1,17 @@
1
+ import { displayGeoName } from '../displayGeoName'
2
+
3
+ describe('displayGeoName', () => {
4
+ it('resolves lowercase world iso codes to country names', () => {
5
+ expect(displayGeoName('ssd')).toBe('South Sudan')
6
+ expect(displayGeoName('usa')).toBe('United States of America')
7
+ })
8
+
9
+ it('prefers the provided display override', () => {
10
+ expect(displayGeoName('ssd', 'Custom South Sudan')).toBe('Custom South Sudan')
11
+ })
12
+
13
+ it('ignores display overrides that only restate the raw code with different casing', () => {
14
+ expect(displayGeoName('ssd', 'Ssd')).toBe('South Sudan')
15
+ expect(displayGeoName('SSD', 'ssd')).toBe('South Sudan')
16
+ })
17
+ })