@cdc/core 4.26.1 → 4.26.2

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 (99) hide show
  1. package/.claude/agents/qa-test-developer.md +126 -0
  2. package/CLAUDE.local.md +67 -0
  3. package/_stories/Gallery.Charts.stories.tsx +34 -41
  4. package/_stories/Gallery.DataBite.stories.tsx +14 -7
  5. package/_stories/Gallery.Maps.stories.tsx +36 -27
  6. package/_stories/Gallery.WaffleChart.stories.tsx +1 -1
  7. package/_stories/PageART.stories.tsx +4 -3
  8. package/_stories/PageBRFSS.stories.tsx +20 -15
  9. package/_stories/PageCancerRegistries.stories.tsx +14 -14
  10. package/_stories/PageEasternEquineEncephalitis.stories.tsx +30 -16
  11. package/_stories/PageExcessiveAlcoholUse.stories.tsx +148 -143
  12. package/_stories/PageMaternalMortality.stories.tsx +4 -3
  13. package/_stories/PageOralHealth.stories.tsx +14 -9
  14. package/_stories/PageSmokingTobacco.stories.tsx +14 -9
  15. package/_stories/PageStateDiabetesProfiles.stories.tsx +14 -9
  16. package/_stories/PageWastewater.stories.tsx +40 -26
  17. package/_stories/VegaImport.stories.tsx +401 -0
  18. package/_stories/vega-fixtures/bars-with-line.json +444 -0
  19. package/_stories/vega-fixtures/bars.json +58 -0
  20. package/_stories/vega-fixtures/combo-bar-rolling-mean.json +88 -0
  21. package/_stories/vega-fixtures/combo.json +68 -0
  22. package/_stories/vega-fixtures/grouped-horizontal-bars.json +83 -0
  23. package/_stories/vega-fixtures/grouped-horizontal-bars2.json +231 -0
  24. package/_stories/vega-fixtures/horizontal-bar.json +427 -0
  25. package/_stories/vega-fixtures/horizontal-bars-with-bad-colors.json +197 -0
  26. package/_stories/vega-fixtures/horizontal-bars2.json +58 -0
  27. package/_stories/vega-fixtures/lines.json +227 -0
  28. package/_stories/vega-fixtures/measles-bars.json +348 -0
  29. package/_stories/vega-fixtures/measles-map.json +11101 -0
  30. package/_stories/vega-fixtures/measles-stacked-bars.json +2147 -0
  31. package/_stories/vega-fixtures/multi-dataset.json +255 -0
  32. package/_stories/vega-fixtures/no-data.json +14 -0
  33. package/_stories/vega-fixtures/pie-chart.json +94 -0
  34. package/_stories/vega-fixtures/repeat-spec.json +47 -0
  35. package/_stories/vega-fixtures/stacked-area.json +222 -0
  36. package/_stories/vega-fixtures/stacked-bar-with-rect.json +3412 -0
  37. package/_stories/vega-fixtures/stacked-bars-with-line.json +364 -0
  38. package/_stories/vega-fixtures/stacked-bars.json +212 -0
  39. package/_stories/vega-fixtures/stacked-horizontal-bars.json +140 -0
  40. package/_stories/vega-fixtures/warning-combo.json +59 -0
  41. package/_stories/vega-fixtures/warning-scatter-and-line.json +1182 -0
  42. package/assets/icon-chart-area.svg +1 -0
  43. package/assets/icon-chart-radar.svg +23 -0
  44. package/assets/logo2.svg +31 -0
  45. package/components/AdvancedEditor/EmbedEditor.tsx +270 -38
  46. package/components/CustomColorsEditor/CustomColorsEditor.tsx +3 -10
  47. package/components/DataTable/helpers/getSeriesName.ts +6 -0
  48. package/components/EditorPanel/VizFilterEditor/NestedDropdownEditor.tsx +14 -6
  49. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +4 -0
  50. package/components/EditorPanel/VizFilterEditor/components/FilterOrder.tsx +33 -29
  51. package/components/Layout/components/Sidebar/components/sidebar.styles.scss +2 -2
  52. package/components/Layout/components/Visualization/index.tsx +11 -0
  53. package/components/MediaControls.tsx +0 -1
  54. package/components/_stories/CustomColorsEditor.stories.tsx +37 -0
  55. package/components/_stories/DataTable.stories.tsx +1 -0
  56. package/data/colorPalettes.ts +18 -5
  57. package/data/mapColorPalettes.ts +10 -0
  58. package/devTemplate/dev.js +235 -0
  59. package/devTemplate/index.html +30 -0
  60. package/devTemplate/preview.html +1503 -0
  61. package/devTemplate/sidebar.css +151 -0
  62. package/dist/cove-main.css +2803 -4471
  63. package/dist/cove-main.css.map +1 -1
  64. package/generateViteConfig.js +111 -2
  65. package/helpers/DataTransform.ts +1 -5
  66. package/helpers/cove/date.ts +33 -1
  67. package/helpers/cove/string.ts +29 -0
  68. package/helpers/coveUpdateWorker.ts +3 -1
  69. package/helpers/embed/embedCodeGenerator.ts +80 -0
  70. package/helpers/embed/embedHelper.js +158 -0
  71. package/helpers/embed/filterUtils.ts +121 -0
  72. package/helpers/embed/index.ts +21 -0
  73. package/helpers/embed/urlValidation.ts +119 -0
  74. package/helpers/filterVizData.ts +6 -1
  75. package/helpers/getFileExtension.ts +0 -6
  76. package/helpers/metrics/types.ts +3 -0
  77. package/helpers/palettes/colorDistributions.ts +1 -1
  78. package/helpers/palettes/utils.ts +12 -12
  79. package/helpers/parseCsvWithQuotes.ts +15 -14
  80. package/helpers/prepareScreenshot.ts +27 -7
  81. package/helpers/testing.ts +44 -0
  82. package/helpers/tests/DataTransform.test.ts +125 -0
  83. package/helpers/tests/date.test.ts +64 -0
  84. package/helpers/vegaConfig.ts +1 -1
  85. package/helpers/vegaConfigImport.ts +160 -0
  86. package/helpers/ver/4.26.1.ts +1 -1
  87. package/helpers/ver/4.26.2.ts +84 -0
  88. package/helpers/ver/tests/4.26.1.test.ts +105 -0
  89. package/helpers/ver/tests/4.26.2.test.ts +298 -0
  90. package/helpers/viewports.ts +2 -0
  91. package/package.json +27 -32
  92. package/styles/v2/components/editor.scss +9 -9
  93. package/styles/v2/utils/_grid.scss +8 -3
  94. package/types/Annotation.ts +10 -11
  95. package/types/General.ts +2 -0
  96. package/types/Palette.ts +21 -0
  97. package/types/Visualization.ts +6 -0
  98. package/_stories/StoryRenderingTests.stories.tsx +0 -164
  99. package/helpers/embedCodeGenerator.ts +0 -109
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Utilities for working with COVE filters in embed contexts
3
+ */
4
+
5
+ export type FilterMetadata = {
6
+ label: string
7
+ key: string
8
+ setByQueryParameter?: string
9
+ values?: any[]
10
+ defaultValue?: string
11
+ active?: string
12
+ }
13
+
14
+ export type FilterState = {
15
+ value: string
16
+ hide: boolean
17
+ }
18
+
19
+ /**
20
+ * Extract filter metadata from a COVE config
21
+ * Handles both regular viz filters and dashboard shared filters
22
+ */
23
+ export function extractFilters(config: any): FilterMetadata[] {
24
+ if (!config) return []
25
+
26
+ // Try regular filters first (charts, maps, etc.)
27
+ if (config.filters && Array.isArray(config.filters) && config.filters.length > 0) {
28
+ return config.filters.map(filter => normalizeFilter(filter))
29
+ }
30
+
31
+ // Try dashboard shared filters
32
+ if (config.dashboard?.sharedFilters && Array.isArray(config.dashboard.sharedFilters)) {
33
+ return config.dashboard.sharedFilters.map(filter => normalizeFilter(filter))
34
+ }
35
+
36
+ return []
37
+ }
38
+
39
+ /**
40
+ * Normalize a filter object to consistent metadata format
41
+ *
42
+ * Different filter types (chart filters vs dashboard filters) may use different field names.
43
+ * This function provides fallbacks to handle these variations:
44
+ *
45
+ * - label: Priority for readability: label > setByQueryParameter > columnName
46
+ * - key: May be called key, columnName, or id depending on the viz type
47
+ * - setByQueryParameter: MUST be used exactly as provided. COVE only recognizes this specific
48
+ * field for URL parameters - there are no fallbacks. Filters without this field cannot be
49
+ * controlled via URL parameters.
50
+ */
51
+ function normalizeFilter(filter: any): FilterMetadata {
52
+ const normalized = {
53
+ label: filter.label || filter.setByQueryParameter || filter.columnName || 'Unnamed Filter',
54
+ key: filter.key || filter.columnName || String(filter.id) || '',
55
+ setByQueryParameter: filter.setByQueryParameter,
56
+ values: filter.values || [],
57
+ defaultValue: filter.defaultValue,
58
+ active: filter.active
59
+ }
60
+ return normalized
61
+ }
62
+
63
+ /**
64
+ * Get initial/default value for a filter
65
+ */
66
+ export function getDefaultFilterValue(filter: FilterMetadata): string {
67
+ if (filter.defaultValue) return filter.defaultValue
68
+ if (filter.active) return filter.active
69
+ if (filter.values && filter.values.length > 0) return filter.values[0]
70
+ return ''
71
+ }
72
+
73
+ /**
74
+ * Initialize filter state from filter metadata
75
+ */
76
+ export function initializeFilterState(filters: FilterMetadata[]): Record<string, FilterState> {
77
+ const state: Record<string, FilterState> = {}
78
+
79
+ filters.forEach(filter => {
80
+ state[filter.key] = {
81
+ value: getDefaultFilterValue(filter),
82
+ hide: false
83
+ }
84
+ })
85
+
86
+ return state
87
+ }
88
+
89
+ /**
90
+ * Build URL parameters from filter state
91
+ * Returns an object with URL parameters for both filter values and hide states
92
+ */
93
+ export function buildFilterUrlParams(
94
+ filters: FilterMetadata[],
95
+ filterState: Record<string, FilterState>
96
+ ): Record<string, string> {
97
+ const urlParams: Record<string, string> = {}
98
+
99
+ filters.forEach(filter => {
100
+ if (!filter.setByQueryParameter) {
101
+ return
102
+ }
103
+
104
+ const state = filterState[filter.key]
105
+ if (!state) {
106
+ return
107
+ }
108
+
109
+ // Add filter value
110
+ if (state.value) {
111
+ urlParams[filter.setByQueryParameter] = state.value
112
+ }
113
+
114
+ // Add hide parameter
115
+ if (state.hide) {
116
+ urlParams[`hide${filter.setByQueryParameter}`] = 'true'
117
+ }
118
+ })
119
+
120
+ return urlParams
121
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Embed-related helpers for COVE visualizations
3
+ * Used for generating embed codes and managing filter customization
4
+ */
5
+
6
+ export { generateEmbedCode, getEmbedPageUrl, getHelperScriptUrl, isDevMode } from './embedCodeGenerator'
7
+
8
+ export {
9
+ extractFilters,
10
+ getDefaultFilterValue,
11
+ initializeFilterState,
12
+ buildFilterUrlParams,
13
+ type FilterMetadata,
14
+ type FilterState
15
+ } from './filterUtils'
16
+
17
+ export { getConfigUrlParam, isValidConfigUrl, isValidMessageOrigin } from './urlValidation'
18
+
19
+ // Import embed helper to initialize iframe resizing
20
+ // This runs immediately when imported
21
+ import './embedHelper.js'
@@ -0,0 +1,119 @@
1
+ /**
2
+ * URL validation utilities for embed functionality
3
+ */
4
+
5
+ /**
6
+ * Gets and validates the configUrl parameter from the current URL
7
+ * Returns the validated configUrl if it's a valid relative URL, null otherwise
8
+ *
9
+ * @returns The validated configUrl or null if missing/invalid
10
+ *
11
+ * @example
12
+ * const configUrl = getConfigUrlParam()
13
+ * if (!configUrl) {
14
+ * // Show error - missing or invalid
15
+ * }
16
+ */
17
+ export function getConfigUrlParam(): string | null {
18
+ const params = new URLSearchParams(window.location.search)
19
+ const configUrl = params.get('configUrl')
20
+
21
+ if (!configUrl) {
22
+ return null
23
+ }
24
+
25
+ // Validate that it's a relative URL
26
+ if (!isValidConfigUrl(configUrl)) {
27
+ return null
28
+ }
29
+
30
+ return configUrl
31
+ }
32
+
33
+ /**
34
+ * Validates that a configUrl is a relative URL (no protocol or host)
35
+ *
36
+ * Only relative URLs are allowed to ensure configs
37
+ * are loaded from the same origin as the embed page.
38
+ *
39
+ * @param configUrl - The URL to validate
40
+ * @returns true if the URL is valid (relative only), false otherwise
41
+ *
42
+ * @example
43
+ * isValidConfigUrl('/path/to/config.json') // true
44
+ * isValidConfigUrl('../config.json') // true
45
+ * isValidConfigUrl('config.json') // true
46
+ * isValidConfigUrl('https://evil.com/config.json') // false
47
+ * isValidConfigUrl('//evil.com/config.json') // false
48
+ * isValidConfigUrl('http://localhost/config.json') // false
49
+ */
50
+ export function isValidConfigUrl(configUrl: string | null): boolean {
51
+ if (!configUrl || typeof configUrl !== 'string') {
52
+ return false
53
+ }
54
+
55
+ const trimmed = configUrl.trim()
56
+
57
+ if (trimmed.length === 0) {
58
+ return false
59
+ }
60
+
61
+ // Reject any URL that contains a protocol (http://, https://, ftp://, etc.)
62
+ if (trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//)) {
63
+ return false
64
+ }
65
+
66
+ // Reject protocol-relative URLs (//example.com/path)
67
+ if (trimmed.startsWith('//')) {
68
+ return false
69
+ }
70
+
71
+ // Reject URLs with protocols but no slashes (javascript:, data:, etc.)
72
+ if (trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:/)) {
73
+ return false
74
+ }
75
+
76
+ // Additional validation: Try to parse it as a URL relative to current origin
77
+ try {
78
+ const parsed = new URL(trimmed, window.location.origin)
79
+
80
+ // Verify it's same origin
81
+ if (parsed.origin !== window.location.origin) {
82
+ return false
83
+ }
84
+
85
+ return true
86
+ } catch (error) {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Validates a postMessage origin against allowed CDC domains
93
+ *
94
+ * @param origin - The origin to validate (from MessageEvent.origin)
95
+ * @returns true if the origin is allowed, false otherwise
96
+ */
97
+ export function isValidMessageOrigin(origin: string): boolean {
98
+ if (!origin || typeof origin !== 'string') {
99
+ return false
100
+ }
101
+
102
+ try {
103
+ const url = new URL(origin)
104
+
105
+ // Allow localhost for development (HTTP only)
106
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
107
+ return url.protocol === 'http:' || url.protocol === 'https:'
108
+ }
109
+
110
+ // Allow cdc.gov and any subdomain (HTTPS only)
111
+ if (url.hostname === 'cdc.gov' || url.hostname.endsWith('.cdc.gov')) {
112
+ return url.protocol === 'https:'
113
+ }
114
+
115
+ return false
116
+ } catch (error) {
117
+ return false
118
+ }
119
+ }
@@ -16,10 +16,15 @@ export const filterVizData = (filters: Filter[], data) => {
16
16
  return []
17
17
  }
18
18
 
19
+ if (!Array.isArray(data)) {
20
+ console.warn('COVE: Data is not an array, received:', typeof data)
21
+ return []
22
+ }
23
+
19
24
  if (!filters) return data
20
25
  const filteredData: any[] = []
21
26
 
22
- data?.forEach(row => {
27
+ data.forEach(row => {
23
28
  let add = true
24
29
  filters
25
30
  .filter(filter => filter.type !== 'url')
@@ -1,9 +1,3 @@
1
- // export const getFileExtensionx = (path: string): string => {
2
- // const regex = /(?:\.([^.]+))?$/
3
- // const outCome: RegExpExecArray | null = regex.exec(path)
4
- // return outCome ? outCome[1] : ''
5
- // }
6
-
7
1
  export const getFileExtension = (url: string): string => {
8
2
  const regexForExtension = /(?:\.([^.]+))$/
9
3
  const regexForQueryParam = /[?&]wt=(csv|json)(?:&|$)/ // Regular expression for 'wt' query parameter
@@ -80,3 +80,6 @@ export type ANALYTICS_EVENT_TYPES =
80
80
 
81
81
  // Image/export events
82
82
  | 'image_download' // generic image download event
83
+
84
+ // Embed events
85
+ | 'embed_loaded' // fired when a visualization is loaded via the embed system
@@ -53,4 +53,4 @@ export const mapV1ColorDistribution = {
53
53
  8: [0, 2, 3, 4, 5, 6, 7, 8],
54
54
  9: [0, 1, 2, 3, 4, 5, 6, 7, 8],
55
55
  10: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
56
- }
56
+ }
@@ -1,16 +1,16 @@
1
- import { map } from 'lodash'
2
1
  import { FALLBACK_COLOR_PALETTE_V1, FALLBACK_COLOR_PALETTE_V2, USE_V2_MIGRATION } from '../constants'
3
2
  import { getColorPaletteVersion } from '../getColorPaletteVersion'
4
3
  import { getPaletteAccessor } from '../getPaletteAccessor'
5
4
  import { chartPaletteMigrationMap } from './migratePaletteName'
6
5
  import { newMapPaletteNames } from './standardizePaletteNames'
6
+ import { Visualization } from '../../types/Visualization'
7
7
 
8
8
  /**
9
9
  * Gets the current palette name from a visualization config
10
10
  * @param config - The visualization config object
11
11
  * @returns The current palette name or empty string if not found
12
12
  */
13
- export const getCurrentPaletteName = (config: any): string => {
13
+ export const getCurrentPaletteName = (config: Partial<Visualization>): string => {
14
14
  // Check new v2 format first
15
15
  if (config?.general?.palette?.name) {
16
16
  return config.general.palette.name
@@ -35,7 +35,7 @@ export const getCurrentPaletteName = (config: any): string => {
35
35
  * @param colorPalettes - The color palettes object (e.g., mapColorPalettes, chartColorPalettes)
36
36
  * @returns The palette colors array or empty array if not found
37
37
  */
38
- export const getPaletteColors = (config: any, colorPalettes: any): string[] => {
38
+ export const getPaletteColors = (config: Partial<Visualization>, colorPalettes: Record<string, Record<string, string[]>>): string[] => {
39
39
  // First check for custom colors (v2 format)
40
40
  if (config?.general?.palette?.customColors) {
41
41
  return config.general.palette.customColors
@@ -65,7 +65,7 @@ export const getPaletteColors = (config: any, colorPalettes: any): string[] => {
65
65
  * @param config - The visualization config object
66
66
  * @returns True if the config is using v1 palette configuration (which would show conversion modal)
67
67
  */
68
- export const isV1Palette = (config: any): boolean => {
68
+ export const isV1Palette = (config: Partial<Visualization>): boolean => {
69
69
  // If v2 migration is disabled globally, don't treat as v1 (no conversion modal)
70
70
  if (!USE_V2_MIGRATION) {
71
71
  return false
@@ -84,7 +84,7 @@ export const isV1Palette = (config: any): boolean => {
84
84
  * @param config - The visualization config object
85
85
  * @returns The fallback palette name for the detected version
86
86
  */
87
- export const getFallbackColorPalette = (config: any): string => {
87
+ export const getFallbackColorPalette = (config: Partial<Visualization>): string => {
88
88
  const paletteVersion = getColorPaletteVersion(config)
89
89
  return paletteVersion === 1 ? FALLBACK_COLOR_PALETTE_V1 : FALLBACK_COLOR_PALETTE_V2
90
90
  }
@@ -161,7 +161,7 @@ export const migratePaletteWithMap = (
161
161
  * @param config - The visualization config object
162
162
  * @returns True if backup data exists
163
163
  */
164
- export const hasPaletteBackup = (config: any): boolean => {
164
+ export const hasPaletteBackup = (config: Partial<Visualization>): boolean => {
165
165
  return !!(config?.general?.palette?.backups?.length > 0)
166
166
  }
167
167
 
@@ -170,7 +170,7 @@ export const hasPaletteBackup = (config: any): boolean => {
170
170
  * @param config - The visualization config object
171
171
  * @returns The original palette name or null if no backup exists
172
172
  */
173
- export const getOriginalPaletteName = (config: any): string | null => {
173
+ export const getOriginalPaletteName = (config: Partial<Visualization>): string | null => {
174
174
  const backups = config?.general?.palette?.backups
175
175
  if (!backups || backups.length === 0) return null
176
176
 
@@ -184,7 +184,7 @@ export const getOriginalPaletteName = (config: any): string | null => {
184
184
  * @param config - The visualization config object
185
185
  * @returns The original two-color palette name or null if no backup exists
186
186
  */
187
- export const getOriginalTwoColorPaletteName = (config: any): string | null => {
187
+ export const getOriginalTwoColorPaletteName = (config: Partial<Visualization>): string | null => {
188
188
  const backups = config?.general?.palette?.backups
189
189
  if (!backups || backups.length === 0) return null
190
190
 
@@ -198,7 +198,7 @@ export const getOriginalTwoColorPaletteName = (config: any): string | null => {
198
198
  * @param config - The visualization config object
199
199
  * @returns True if two-color backup data exists
200
200
  */
201
- export const hasTwoColorPaletteBackup = (config: any): boolean => {
201
+ export const hasTwoColorPaletteBackup = (config: Partial<Visualization>): boolean => {
202
202
  const backups = config?.general?.palette?.backups
203
203
  if (!backups || backups.length === 0) return false
204
204
  return backups.some((backup: any) => backup.type === 'twoColor')
@@ -209,7 +209,7 @@ export const hasTwoColorPaletteBackup = (config: any): boolean => {
209
209
  * @param config - The visualization config object to modify
210
210
  * @returns True if rollback was successful, false if no backup available
211
211
  */
212
- export const rollbackPaletteToOriginal = (config: any): boolean => {
212
+ export const rollbackPaletteToOriginal = (config: Partial<Visualization>): boolean => {
213
213
  const backups = config?.general?.palette?.backups
214
214
  if (!backups || backups.length === 0) {
215
215
  return false
@@ -230,7 +230,7 @@ export const rollbackPaletteToOriginal = (config: any): boolean => {
230
230
  config.general.palette.version = '1.0' // Reset to v1
231
231
  }
232
232
 
233
- return config
233
+ return true
234
234
  }
235
235
 
236
236
  /**
@@ -238,7 +238,7 @@ export const rollbackPaletteToOriginal = (config: any): boolean => {
238
238
  * @param config - The visualization config object to modify
239
239
  * @returns True if rollback was successful, false if no backup available
240
240
  */
241
- export const rollbackTwoColorPaletteToOriginal = (config: any): boolean => {
241
+ export const rollbackTwoColorPaletteToOriginal = (config: Partial<Visualization>): boolean => {
242
242
  const backups = config?.general?.palette?.backups
243
243
  if (!backups || backups.length === 0) {
244
244
  return false
@@ -2,7 +2,7 @@ import Papa from 'papaparse'
2
2
 
3
3
  /**
4
4
  * Parses CSV text while preserving newlines and commas within quoted fields.
5
- *
5
+ *
6
6
  * @param responseText - The raw CSV text to parse
7
7
  * @param options - Parsing options
8
8
  * @param options.delimiter - The delimiter to use after processing (default: '|')
@@ -17,27 +17,28 @@ export function parseCsvWithQuotes(
17
17
  } = {}
18
18
  ): any[] {
19
19
  const { delimiter = '|', dynamicTyping = false } = options
20
-
20
+
21
21
  const NEWLINE_PLACEHOLDER = '__COVE_NEWLINE__'
22
-
22
+ const newlinePlaceholderRegex = new RegExp(NEWLINE_PLACEHOLDER, 'g')
23
+
23
24
  // Preserve newlines in quoted fields by replacing with placeholder
24
25
  const quotedFields: string[] = []
25
26
  let placeholderIndex = 0
26
- let sanitizedText = responseText.replace(/("(?:[^"\\]|\\.|[\s\S])*?")/g, (match) => {
27
+ let sanitizedText = responseText.replace(/("(?:[^"\\]|\\.|[\s\S])*?")/g, match => {
27
28
  const preserved = match.replace(/\n/g, NEWLINE_PLACEHOLDER)
28
29
  quotedFields.push(preserved)
29
30
  return `__QUOTED_FIELD_${placeholderIndex++}__`
30
31
  })
31
-
32
+
32
33
  // Replace commas outside quoted fields with pipe delimiter
33
34
  sanitizedText = sanitizedText.replace(/(__QUOTED_FIELD_\d+__)|,/g, (...m) => m[1] || delimiter)
34
-
35
- // Restore quoted fields without outer quotes
36
- quotedFields.forEach((field, index) => {
37
- const unquoted = field.slice(1, -1).replace(new RegExp(NEWLINE_PLACEHOLDER, 'g'), '\n')
38
- sanitizedText = sanitizedText.replace(`__QUOTED_FIELD_${index}__`, unquoted)
35
+
36
+ // Restore quoted fields without outer quotes (single pass instead of N passes)
37
+ sanitizedText = sanitizedText.replace(/__QUOTED_FIELD_(\d+)__/g, (_, idx) => {
38
+ const field = quotedFields[parseInt(idx, 10)]
39
+ return field.slice(1, -1).replace(newlinePlaceholderRegex, '\n')
39
40
  })
40
-
41
+
41
42
  // Parse with Papa.parse
42
43
  const parsedCsv = Papa.parse(sanitizedText, {
43
44
  header: true,
@@ -45,21 +46,21 @@ export function parseCsvWithQuotes(
45
46
  delimiter,
46
47
  dynamicTyping
47
48
  })
48
-
49
+
49
50
  // Restore newlines in parsed data
50
51
  const restoredData = parsedCsv.data.map((row: any) => {
51
52
  const restoredRow: any = {}
52
53
  Object.keys(row).forEach(key => {
53
54
  const value = row[key]
54
55
  if (typeof value === 'string') {
55
- restoredRow[key] = value.replace(new RegExp(NEWLINE_PLACEHOLDER, 'g'), '\n')
56
+ restoredRow[key] = value.replace(newlinePlaceholderRegex, '\n')
56
57
  } else {
57
58
  restoredRow[key] = value
58
59
  }
59
60
  })
60
61
  return restoredRow
61
62
  })
62
-
63
+
63
64
  return restoredData
64
65
  }
65
66
 
@@ -208,6 +208,23 @@ function convertCanvasToImages(baseSvg: HTMLElement, clonedViz: HTMLElement): vo
208
208
  })
209
209
  }
210
210
 
211
+ /**
212
+ * Remove width classes that interfere with screenshot sizing
213
+ */
214
+ function removeWidthClasses(clonedTree: HTMLElement): void {
215
+ const classesToRemove = ['dfe-block--width-wide', 'dfe-block--width-full_width']
216
+
217
+ classesToRemove.forEach(className => {
218
+ const elements = clonedTree.querySelectorAll(`.${className}`)
219
+ elements.forEach(el => el.classList.remove(className))
220
+ })
221
+
222
+ // Also check the root element itself
223
+ classesToRemove.forEach(className => {
224
+ clonedTree.classList.remove(className)
225
+ })
226
+ }
227
+
211
228
  /**
212
229
  * Expand SVG widths and remove animation classes
213
230
  */
@@ -237,31 +254,34 @@ export function prepareScreenshotContainer(
237
254
  // 1. Clone elements (with or without context)
238
255
  const { clonedTree, clonedViz } = prepareClonedElements(baseSvg, includeContextInDownload, elementToCapture)
239
256
 
240
- // 2. Strip all links (not clickable in static image)
257
+ // 2. Remove width classes that interfere with screenshot sizing
258
+ removeWidthClasses(clonedTree)
259
+
260
+ // 3. Strip all links (not clickable in static image)
241
261
  stripLinks(clonedTree)
242
262
 
243
- // 3. Convert canvas elements to images
263
+ // 4. Convert canvas elements to images
244
264
  convertCanvasToImages(baseSvg, clonedViz)
245
265
 
246
- // 4. Expand SVG widths to prevent clipping
266
+ // 5. Expand SVG widths to prevent clipping
247
267
  expandSvgWidths(clonedViz)
248
268
 
249
- // 5. Calculate viz dimensions
269
+ // 6. Calculate viz dimensions
250
270
  const computedStyle = getComputedStyle(baseSvg)
251
271
  const vizWidth =
252
272
  parseFloat(computedStyle.width) -
253
273
  (parseFloat(computedStyle.paddingLeft) || 0) -
254
274
  (parseFloat(computedStyle.paddingRight) || 0)
255
275
 
256
- // 6. Create and style container
276
+ // 7. Create and style container
257
277
  const container = document.createElement('div')
258
278
  container.style.width = `${vizWidth + 36}px`
259
279
  container.style.padding = '18px'
260
280
 
261
- // 7. Reset viz padding
281
+ // 8. Reset viz padding
262
282
  clonedViz.style.padding = '0'
263
283
 
264
- // 8. Append cloned tree to container
284
+ // 9. Append cloned tree to container
265
285
  container.appendChild(clonedTree)
266
286
 
267
287
  return container
@@ -313,6 +313,50 @@ export const testBooleanControl = async (checkbox: HTMLInputElement, getVisualSt
313
313
  expect(secondToggleVisualState).toEqual(initialVisualState)
314
314
  }
315
315
 
316
+ // ============================================================================
317
+ // VISUALIZATION RENDERING ASSERTIONS
318
+ // ============================================================================
319
+
320
+ /**
321
+ * Assert that a visualization has rendered successfully.
322
+ * Uses performAndAssert to poll until one of these conditions is met:
323
+ * ((svgCount > 0 || canvasCount > 0) && hasCoveModule) || isDataBite || isDataTable
324
+ *
325
+ * Handles all visualization types:
326
+ * - Charts (SVG-based)
327
+ * - Maps with SVG rendering (US state, world, hex, etc.)
328
+ * - Maps with canvas rendering (county maps via UsaMap.County)
329
+ * - Data bites (.bite-content)
330
+ * - Data tables (.data-table)
331
+ * - Waffle charts (SVG-based)
332
+ *
333
+ * Use as a play function in any Components/Templates/ story:
334
+ * play: async ({ canvasElement }) => {
335
+ * await assertVisualizationRendered(canvasElement)
336
+ * }
337
+ *
338
+ * @param vizElement The story's canvas element (Storybook's canvasElement)
339
+ */
340
+ export const assertVisualizationRendered = async (vizElement: HTMLElement) => {
341
+ await performAndAssert(
342
+ 'Wait for visualization to render',
343
+ () => {
344
+ const svgCount = vizElement.querySelectorAll('svg').length
345
+ const canvasCount = vizElement.querySelectorAll('canvas').length
346
+ const hasCoveModule = !!vizElement.querySelector('.cdc-open-viz-module')
347
+ const isDataBite = !!vizElement.querySelector('.bite-content')
348
+ const isDataTable = !!vizElement.querySelector('.type-data-table')
349
+ return { svgCount, canvasCount, hasCoveModule, isDataBite, isDataTable }
350
+ },
351
+ async () => {},
352
+ (_before, after) => {
353
+ return (
354
+ ((after.svgCount > 0 || after.canvasCount > 0) && after.hasCoveModule) || after.isDataBite || after.isDataTable
355
+ )
356
+ }
357
+ )
358
+ }
359
+
316
360
  // ============================================================================
317
361
  // DATA EXTRACTION HELPERS
318
362
  // ============================================================================