@cdc/map 4.25.10 → 4.25.11

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 (88) 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 +27405 -25783
  7. package/examples/example-city-state.json +9 -1
  8. package/examples/multi-country-centering.json +45 -0
  9. package/examples/private/colors-2.json +221 -0
  10. package/examples/private/colors.json +221 -0
  11. package/index.html +2 -1
  12. package/package.json +4 -4
  13. package/src/CdcMapComponent.tsx +44 -20
  14. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  15. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  16. package/src/_stories/CdcMap.Editor.stories.tsx +3371 -0
  17. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  18. package/src/_stories/CdcMap.stories.tsx +22 -4
  19. package/src/_stories/_mock/column-wrap-test.json +265 -0
  20. package/src/_stories/_mock/multi-country-hide.json +78 -0
  21. package/src/_stories/_mock/multi-country.json +95 -0
  22. package/src/_stories/_mock/multi-state.json +887 -20403
  23. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  24. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  25. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  26. package/src/_stories/_mock/usa-state-gradient.json +2 -4
  27. package/src/components/BubbleList.tsx +1 -1
  28. package/src/components/EditorPanel/components/EditorPanel.tsx +630 -564
  29. package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
  30. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
  31. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +354 -0
  32. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  33. package/src/components/Geo.tsx +20 -3
  34. package/src/components/Legend/components/Legend.tsx +34 -34
  35. package/src/components/Legend/components/index.scss +1 -1
  36. package/src/components/NavigationMenu.tsx +16 -13
  37. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  38. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  39. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  40. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  41. package/src/components/SmallMultiples/index.tsx +3 -0
  42. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +18 -3
  43. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  44. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  45. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +23 -4
  46. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +6 -0
  47. package/src/components/UsaMap/components/UsaMap.County.tsx +14 -2
  48. package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
  49. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +25 -5
  50. package/src/components/UsaMap/components/UsaMap.State.tsx +26 -3
  51. package/src/components/UsaMap/helpers/map.ts +2 -2
  52. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  53. package/src/components/WorldMap/WorldMap.tsx +81 -11
  54. package/src/data/initial-state.js +10 -0
  55. package/src/data/supported-geos.js +8 -76
  56. package/src/helpers/addUIDs.ts +13 -2
  57. package/src/helpers/applyColorToLegend.ts +25 -1
  58. package/src/helpers/constants.ts +1 -15
  59. package/src/helpers/displayGeoName.ts +19 -4
  60. package/src/helpers/generateRuntimeLegend.ts +0 -2
  61. package/src/helpers/getCountriesPicked.ts +103 -0
  62. package/src/helpers/getMapContainerClasses.ts +7 -0
  63. package/src/helpers/getPatternForRow.ts +2 -5
  64. package/src/helpers/index.ts +1 -9
  65. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  66. package/src/helpers/tests/titleCase.test.ts +76 -0
  67. package/src/helpers/titleCase.ts +13 -13
  68. package/src/helpers/urlDataHelpers.ts +1 -1
  69. package/src/hooks/useCountryZoom.tsx +241 -0
  70. package/src/hooks/useGeoClickHandler.ts +1 -1
  71. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  72. package/src/hooks/useResizeObserver.ts +5 -2
  73. package/src/hooks/useStateZoom.tsx +5 -2
  74. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  75. package/src/index.jsx +1 -0
  76. package/src/scss/editor-panel.scss +4 -440
  77. package/src/scss/main.scss +1 -1
  78. package/src/scss/map.scss +12 -15
  79. package/src/store/map.actions.ts +7 -7
  80. package/src/types/MapConfig.ts +30 -11
  81. package/src/types/MapContext.ts +6 -0
  82. package/src/types/runtimeLegend.ts +1 -1
  83. package/src/components/DataTable.tsx +0 -413
  84. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  85. package/src/hooks/useActiveElement.ts +0 -19
  86. package/src/scss/mixins.scss +0 -47
  87. package/src/types/Annotations.ts +0 -24
  88. /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
@@ -19,12 +19,4 @@ export { titleCase as toTitleCase } from './toTitleCase'
19
19
  export { titleCase } from './titleCase'
20
20
  export { validateFipsCodeLength } from './validateFipsCodeLength'
21
21
  export { getMapContainerClasses } from './getMapContainerClasses'
22
- export {
23
- SVG_HEIGHT,
24
- SVG_WIDTH,
25
- SVG_PADDING,
26
- SVG_VIEWBOX,
27
- HEADER_COLORS,
28
- MAX_ZOOM_LEVEL,
29
- DEFAULT_MAP_BACKGROUND
30
- } from './constants'
22
+ export { SVG_HEIGHT, SVG_WIDTH, SVG_PADDING, SVG_VIEWBOX, MAX_ZOOM_LEVEL, DEFAULT_MAP_BACKGROUND } from './constants'
@@ -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
+ }
@@ -0,0 +1,76 @@
1
+ import { titleCase } from '../titleCase'
2
+
3
+ describe('titleCase', () => {
4
+ it('should return undefined for falsy input', () => {
5
+ expect(titleCase(undefined)).toBeUndefined()
6
+ expect(titleCase(null)).toBeUndefined()
7
+ expect(titleCase('')).toBeUndefined()
8
+ })
9
+
10
+ it('should convert simple strings to title case', () => {
11
+ expect(titleCase('hello world')).toBe('Hello World')
12
+ expect(titleCase('HELLO WORLD')).toBe('Hello World')
13
+ expect(titleCase('HeLLo WoRLd')).toBe('Hello World')
14
+ })
15
+
16
+ it('should keep "of", "the", and "and" lowercase in title case', () => {
17
+ expect(titleCase('DISTRICT OF COLUMBIA')).toBe('District of Columbia')
18
+ expect(titleCase('District Of Columbia')).toBe('District of Columbia')
19
+ expect(titleCase('district of columbia')).toBe('District of Columbia')
20
+ })
21
+
22
+ it('should handle "Federated States of Micronesia" correctly', () => {
23
+ expect(titleCase('FEDERATED STATES OF MICRONESIA')).toBe('Federated States of Micronesia')
24
+ expect(titleCase('Federated States Of Micronesia')).toBe('Federated States of Micronesia')
25
+ })
26
+
27
+ it('should handle "Republic of the Congo" correctly', () => {
28
+ expect(titleCase('REPUBLIC OF THE CONGO')).toBe('Republic of the Congo')
29
+ expect(titleCase('Republic Of The Congo')).toBe('Republic of the Congo')
30
+ })
31
+
32
+ it('should handle "and" in country names correctly', () => {
33
+ expect(titleCase('ANTIGUA AND BARBUDA')).toBe('Antigua and Barbuda')
34
+ expect(titleCase('TRINIDAD AND TOBAGO')).toBe('Trinidad and Tobago')
35
+ expect(titleCase('BOSNIA AND HERZEGOVINA')).toBe('Bosnia and Herzegovina')
36
+ })
37
+
38
+ it('should handle hyphenated strings correctly', () => {
39
+ expect(titleCase('INTER-TRIBAL INDIAN RESERVATION')).toBe('Inter-Tribal Indian Reservation')
40
+ expect(titleCase('inter-tribal indian reservation')).toBe('Inter-Tribal Indian Reservation')
41
+ })
42
+
43
+ it('should handle en dash strings correctly', () => {
44
+ expect(titleCase('PUERTO RICO–VIRGIN ISLANDS')).toBe('Puerto Rico–Virgin Islands')
45
+ expect(titleCase('puerto rico–virgin islands')).toBe('Puerto Rico–Virgin Islands')
46
+ })
47
+
48
+ it('should handle mixed case strings with "of"', () => {
49
+ expect(titleCase('UNIVERSITY OF WASHINGTON')).toBe('University of Washington')
50
+ expect(titleCase('STATE OF ALASKA')).toBe('State of Alaska')
51
+ })
52
+
53
+ it('should handle single words', () => {
54
+ expect(titleCase('CALIFORNIA')).toBe('California')
55
+ expect(titleCase('california')).toBe('California')
56
+ })
57
+
58
+ it('should handle strings with multiple "of" and "the" occurrences', () => {
59
+ expect(titleCase('OFFICE OF THE STATE OF CALIFORNIA')).toBe('Office of the State of California')
60
+ expect(titleCase('DEMOCRATIC REPUBLIC OF THE CONGO')).toBe('Democratic Republic of the Congo')
61
+ })
62
+
63
+ it('should handle hyphenated strings with "of", "the", and "and"', () => {
64
+ expect(titleCase('KINGDOM OF THE NETHERLANDS-ARUBA')).toBe('Kingdom of the Netherlands-Aruba')
65
+ expect(titleCase('SAINT VINCENT AND THE GRENADINES-ISLAND')).toBe('Saint Vincent and the Grenadines-Island')
66
+ })
67
+
68
+ it('should handle "Commonwealth of the Northern Mariana Islands"', () => {
69
+ expect(titleCase('COMMONWEALTH OF THE NORTHERN MARIANA ISLANDS')).toBe(
70
+ 'Commonwealth of the Northern Mariana Islands'
71
+ )
72
+ expect(titleCase('commonwealth of the northern mariana islands')).toBe(
73
+ 'Commonwealth of the Northern Mariana Islands'
74
+ )
75
+ })
76
+ })
@@ -5,25 +5,25 @@ export const titleCase = string => {
5
5
  // guard clause else error in editor
6
6
  if (!string) return
7
7
  if (string !== undefined) {
8
+ // Words that should remain lowercase in geographic names
9
+ const lowercaseWords = ['of', 'the', 'and']
10
+
11
+ const titleCaseWord = (word: string): string => {
12
+ const lowerWord = word.toLowerCase()
13
+ return lowercaseWords.includes(lowerWord)
14
+ ? lowerWord
15
+ : word.charAt(0).toUpperCase() + word.substring(1).toLowerCase()
16
+ }
17
+
8
18
  // if hyphen found, then split, uppercase each word, and put back together
9
19
  if (string.includes('–') || string.includes('-')) {
10
20
  let dashSplit = string.includes('–') ? string.split('–') : string.split('-') // determine hyphen or en dash to split on
11
21
  let splitCharacter = string.includes('–') ? '–' : '-' // print hyphen or en dash later on.
12
- let frontSplit = dashSplit[0]
13
- .split(' ')
14
- .map(word => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase())
15
- .join(' ')
16
- let backSplit = dashSplit[1]
17
- .split(' ')
18
- .map(word => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase())
19
- .join(' ')
22
+ let frontSplit = dashSplit[0].split(' ').map(titleCaseWord).join(' ')
23
+ let backSplit = dashSplit[1].split(' ').map(titleCaseWord).join(' ')
20
24
  return frontSplit + splitCharacter + backSplit
21
25
  } else {
22
- // just return with each word uppercase
23
- return string
24
- .split(' ')
25
- .map(word => (word === 'of' ? word : word.charAt(0).toUpperCase() + word.substring(1).toLowerCase()))
26
- .join(' ')
26
+ return string.split(' ').map(titleCaseWord).join(' ')
27
27
  }
28
28
  }
29
29
  }
@@ -6,7 +6,7 @@ import { MapConfig } from '../types/MapConfig'
6
6
  import { CSV_PARSE_CONFIG } from './constants'
7
7
  import { cloneConfig } from '@cdc/core/helpers/cloneConfig'
8
8
 
9
- export const buildQueryString = (params: Record<string, string>): string =>
9
+ const buildQueryString = (params: Record<string, string>): string =>
10
10
  Object.keys(params)
11
11
  .map((param, i) => {
12
12
  let qs = i === 0 ? '?' : '&'