@cdc/core 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 (134) hide show
  1. package/_stories/Gallery.Charts.stories.tsx +307 -0
  2. package/_stories/Gallery.DataBite.stories.tsx +72 -0
  3. package/_stories/Gallery.Maps.stories.tsx +230 -0
  4. package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
  5. package/_stories/PageART.stories.tsx +192 -0
  6. package/_stories/PageBRFSS.stories.tsx +289 -0
  7. package/_stories/PageCancerRegistries.stories.tsx +199 -0
  8. package/_stories/PageEasternEquineEncephalitis.stories.tsx +202 -0
  9. package/_stories/PageExcessiveAlcoholUse.stories.tsx +196 -0
  10. package/_stories/PageMaternalMortality.stories.tsx +192 -0
  11. package/_stories/PageOralHealth.stories.tsx +196 -0
  12. package/_stories/PageRespiratory.stories.tsx +332 -0
  13. package/_stories/PageSmokingTobacco.stories.tsx +195 -0
  14. package/_stories/PageStateDiabetesProfiles.stories.tsx +196 -0
  15. package/_stories/PageWastewater.stories.tsx +463 -0
  16. package/_stories/StoryRenderingTests.stories.tsx +164 -0
  17. package/assets/icon-magnifying-glass.svg +5 -0
  18. package/assets/icon-warming-stripes.svg +13 -0
  19. package/components/AdvancedEditor/AdvancedEditor.tsx +7 -1
  20. package/components/AdvancedEditor/EmbedEditor.tsx +281 -0
  21. package/components/ComboBox/ComboBox.tsx +345 -0
  22. package/components/ComboBox/combobox.styles.css +185 -0
  23. package/components/ComboBox/index.ts +1 -0
  24. package/components/CustomColorsEditor/CustomColorsEditor.css +299 -0
  25. package/components/CustomColorsEditor/CustomColorsEditor.tsx +209 -0
  26. package/components/CustomColorsEditor/index.ts +1 -0
  27. package/components/DataTable/DataTable.tsx +132 -58
  28. package/components/DataTable/DataTableStandAlone.tsx +8 -3
  29. package/components/DataTable/components/DataTableEditorPanel.tsx +12 -2
  30. package/components/DataTable/data-table.css +217 -210
  31. package/components/DataTable/helpers/mapCellMatrix.tsx +28 -9
  32. package/components/DataTable/helpers/standardizeState.js +2 -2
  33. package/components/DataTable/helpers/tests/standardizeState.test.js +54 -0
  34. package/components/EditorPanel/ColumnsEditor.tsx +37 -19
  35. package/components/EditorPanel/DataTableEditor.tsx +54 -28
  36. package/components/EditorPanel/EditorPanel.styles.css +439 -0
  37. package/components/EditorPanel/EditorPanel.tsx +144 -0
  38. package/components/EditorPanel/EditorPanelDispatch.tsx +75 -0
  39. package/components/EditorPanel/FieldSetWrapper.tsx +66 -23
  40. package/components/EditorPanel/FootnotesEditor.tsx +44 -37
  41. package/components/EditorPanel/Inputs.tsx +44 -8
  42. package/components/EditorPanel/VizFilterEditor/NestedDropdownEditor.tsx +35 -62
  43. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +246 -175
  44. package/components/EditorPanel/components/MarkupVariablesEditor.tsx +61 -22
  45. package/components/EditorPanel/sections/VisualSection.tsx +169 -0
  46. package/components/Filters/Filters.tsx +57 -10
  47. package/components/Filters/components/Dropdown.tsx +6 -1
  48. package/components/Filters/helpers/getNestedOptions.ts +2 -1
  49. package/components/Filters/helpers/handleSorting.ts +1 -1
  50. package/components/Footnotes/Footnotes.tsx +35 -25
  51. package/components/Footnotes/FootnotesStandAlone.tsx +42 -6
  52. package/components/HeaderThemeSelector/HeaderThemeSelector.css +43 -0
  53. package/components/HeaderThemeSelector/HeaderThemeSelector.stories.tsx +74 -0
  54. package/components/HeaderThemeSelector/HeaderThemeSelector.tsx +61 -0
  55. package/components/HeaderThemeSelector/index.ts +2 -0
  56. package/components/Layout/components/Sidebar/components/sidebar.styles.scss +82 -0
  57. package/components/Layout/components/Visualization/index.tsx +16 -1
  58. package/components/Layout/components/Visualization/visualizations.scss +7 -0
  59. package/components/Layout/styles/editor.scss +2 -1
  60. package/components/Legend/Legend.Gradient.tsx +1 -1
  61. package/components/Loader/Loader.tsx +1 -1
  62. package/components/MediaControls.tsx +63 -34
  63. package/components/PaletteConversionModal.tsx +7 -4
  64. package/components/PaletteSelector/PaletteSelector.css +49 -6
  65. package/components/Table/components/Cell.tsx +23 -2
  66. package/components/Table/components/Row.tsx +5 -3
  67. package/components/_stories/Filters.stories.tsx +20 -1
  68. package/components/_stories/Footnotes.CSV.stories.tsx +247 -0
  69. package/components/_stories/Footnotes.stories.tsx +768 -3
  70. package/components/_stories/Inputs.stories.tsx +2 -2
  71. package/components/_stories/styles.scss +0 -1
  72. package/components/ui/Accordion.jsx +1 -1
  73. package/components/ui/Icon.tsx +3 -1
  74. package/components/ui/Title/index.tsx +30 -2
  75. package/components/ui/Title/title.styles.css +42 -0
  76. package/components/ui/accordion.styles.css +57 -0
  77. package/data/chartColorPalettes.ts +1 -1
  78. package/dist/cove-main.css +75 -6
  79. package/dist/cove-main.css.map +1 -1
  80. package/generateViteConfig.js +8 -1
  81. package/helpers/addValuesToFilters.ts +11 -1
  82. package/helpers/constants.ts +37 -0
  83. package/helpers/cove/number.ts +33 -12
  84. package/helpers/coveUpdateWorker.ts +20 -11
  85. package/helpers/embedCodeGenerator.ts +109 -0
  86. package/helpers/fetchRemoteData.ts +3 -15
  87. package/helpers/getUniqueValues.ts +19 -0
  88. package/helpers/hashObj.ts +25 -0
  89. package/helpers/isRightAlignedTableValue.js +5 -0
  90. package/helpers/markupProcessor.ts +27 -12
  91. package/helpers/mergeCustomOrderValues.ts +37 -0
  92. package/helpers/metrics/helpers.ts +1 -0
  93. package/helpers/parseCsvWithQuotes.ts +65 -0
  94. package/helpers/pivotData.ts +2 -2
  95. package/helpers/prepareScreenshot.ts +268 -0
  96. package/helpers/queryStringUtils.ts +29 -0
  97. package/helpers/testing.ts +17 -4
  98. package/helpers/tests/prepareScreenshot.test.ts +414 -0
  99. package/helpers/tests/queryStringUtils.test.ts +381 -0
  100. package/helpers/tests/testStandaloneBuild.ts +23 -5
  101. package/helpers/useDataVizClasses.ts +0 -1
  102. package/helpers/ver/4.25.11.ts +13 -0
  103. package/helpers/ver/4.26.1.ts +80 -0
  104. package/helpers/viewports.ts +2 -0
  105. package/hooks/useDataColumns.ts +63 -0
  106. package/hooks/useFilterManagement.ts +94 -0
  107. package/hooks/useLegendSeparators.ts +26 -0
  108. package/hooks/useListManagement.ts +192 -0
  109. package/package.json +6 -4
  110. package/styles/_button-section.scss +0 -3
  111. package/styles/_common-components.css +73 -0
  112. package/styles/_global.scss +25 -5
  113. package/styles/base.scss +0 -50
  114. package/styles/cove-main.scss +3 -1
  115. package/styles/filters.scss +10 -3
  116. package/styles/v2/base/index.scss +0 -1
  117. package/styles/v2/components/editor.scss +14 -6
  118. package/styles/v2/utils/_breakpoints.scss +1 -1
  119. package/styles/v2/utils/index.scss +0 -1
  120. package/styles/waiting.scss +1 -1
  121. package/types/Axis.ts +1 -0
  122. package/types/ForecastingSeriesKey.ts +1 -0
  123. package/types/MarkupInclude.ts +5 -3
  124. package/types/MarkupVariable.ts +1 -1
  125. package/types/Series.ts +3 -0
  126. package/types/Table.ts +1 -0
  127. package/types/Visualization.ts +1 -0
  128. package/types/VizFilter.ts +2 -0
  129. package/LICENSE +0 -201
  130. package/styles/_mixins.scss +0 -13
  131. package/styles/_typography.scss +0 -0
  132. package/styles/v2/base/_typography.scss +0 -0
  133. package/styles/v2/components/guidance-block.scss +0 -74
  134. package/styles/v2/utils/_functions.scss +0 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Generates iframe embed code for COVE visualizations
3
+ * Used by editor's "Share with Partners" feature
4
+ */
5
+
6
+ interface EmbedCodeOptions {
7
+ configUrl: string
8
+ width?: string
9
+ height?: string
10
+ embedBaseUrl?: string
11
+ helperScriptUrl?: string
12
+ /** Additional URL parameters (e.g., filter values, hide flags) */
13
+ urlParams?: Record<string, string>
14
+ }
15
+
16
+ /**
17
+ * Detect if we're in development mode
18
+ */
19
+ export function isDevMode(): boolean {
20
+ if (typeof window === 'undefined') return false
21
+ return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
22
+ }
23
+
24
+ /**
25
+ * Get default embed base URL based on environment
26
+ * Returns full absolute URL including protocol and host
27
+ */
28
+ export function getDefaultEmbedBaseUrl(): string {
29
+ if (isDevMode()) {
30
+ return 'http://localhost:8080'
31
+ }
32
+ return 'https://www.cdc.gov/TemplatePackage/contrib/widgets/openVizWrapper/dist/embed/embed.html'
33
+ }
34
+
35
+ /**
36
+ * Get embed path (without protocol/host) for same-origin usage
37
+ * Use this for preview iframes on the same domain
38
+ */
39
+ export function getEmbedPath(): string {
40
+ if (isDevMode()) {
41
+ return '/'
42
+ }
43
+ return '/TemplatePackage/contrib/widgets/openVizWrapper/dist/embed/embed.html'
44
+ }
45
+
46
+ /**
47
+ * Get default embed helper script URL based on environment
48
+ */
49
+ export function getDefaultHelperScriptUrl(): string {
50
+ if (isDevMode()) {
51
+ return 'http://localhost:8080/src/embed-helper/index.js'
52
+ }
53
+ return 'https://www.cdc.gov/TemplatePackage/contrib/widgets/openVizWrapper/dist/embed/embed-helper.js'
54
+ }
55
+
56
+ /**
57
+ * Get default generator base URL based on environment
58
+ */
59
+ export function getDefaultGeneratorBaseUrl(): string {
60
+ if (isDevMode()) {
61
+ return 'http://localhost:8080/generator.html'
62
+ }
63
+ return 'https://www.cdc.gov/TemplatePackage/contrib/widgets/openVizWrapper/dist/embed/generator.html'
64
+ }
65
+
66
+ /**
67
+ * Generate basic iframe embed code for partners
68
+ *
69
+ * @param options.configUrl - URL to the published config JSON
70
+ * @param options.width - iframe width (default: "100%")
71
+ * @param options.height - iframe height (default: "300")
72
+ * @param options.embedBaseUrl - Base URL for embed page (auto-detected by environment)
73
+ * @param options.helperScriptUrl - URL for embed-helper.js (auto-detected by environment)
74
+ * @param options.urlParams - Additional URL parameters (e.g., filter values, hide flags)
75
+ * @returns HTML string with iframe and script tag
76
+ */
77
+ export function generateEmbedCode(options: EmbedCodeOptions): string {
78
+ const {
79
+ configUrl,
80
+ width = '100%',
81
+ height = '300',
82
+ embedBaseUrl = getDefaultEmbedBaseUrl(),
83
+ helperScriptUrl = getDefaultHelperScriptUrl(),
84
+ urlParams = {}
85
+ } = options
86
+
87
+ // Construct embed page URL with config parameter and any additional params
88
+ const params = new URLSearchParams()
89
+ params.set('configUrl', configUrl)
90
+
91
+ // Add any additional URL parameters (filters, hide flags, etc.)
92
+ Object.entries(urlParams).forEach(([key, value]) => {
93
+ if (value) params.set(key, value)
94
+ })
95
+
96
+ const embedUrl = `${embedBaseUrl}?${params.toString()}`
97
+
98
+ // Generate iframe code
99
+ const iframeCode = `<iframe src="${embedUrl}"
100
+ data-cove-embed
101
+ width="${width}"
102
+ height="${height}"
103
+ frameborder="0"
104
+ title="CDC Data Visualization"
105
+ ></iframe>
106
+ <script src="${helperScriptUrl}"></script>`
107
+
108
+ return iframeCode
109
+ }
@@ -1,5 +1,5 @@
1
- import Papa from 'papaparse'
2
1
  import { isSolrCsv } from '@cdc/core/helpers/isSolr'
2
+ import { parseCsvWithQuotes } from '@cdc/core/helpers/parseCsvWithQuotes'
3
3
 
4
4
  export default function fetchRemoteData(_url) {
5
5
  let url = new URL(_url, window.location.origin)
@@ -11,22 +11,10 @@ export default function fetchRemoteData(_url) {
11
11
  return fetch(url.href)
12
12
  .then(response => response.text())
13
13
  .then(responseText => {
14
- // for every comma NOT inside quotes, replace with a pipe delimiter
15
- // - this will let commas inside the quotes not be parsed as a new column
16
- // - Limitation: if a delimiter other than comma is used in the csv this will break
17
- // Examples of other delimiters that would break: tab
18
- responseText = responseText.replace(/(".*?")|,/g, (...m) => m[1] || '|')
19
- // now strip the double quotes
20
- responseText = responseText.replace(/["]+/g, '')
21
- const parsedCsv = Papa.parse(responseText, {
22
- //quotes: "true", // dont need these
23
- //quoteChar: "'", // has no effect that I can tell
24
- header: true,
25
- skipEmptyLines: true,
26
- delimiter: '|', // we are using pipe symbol as delimiter so setting this explicitly for Papa.parse
14
+ return parseCsvWithQuotes(responseText, {
15
+ delimiter: '|',
27
16
  dynamicTyping: false
28
17
  })
29
- return parsedCsv.data
30
18
  })
31
19
  } else {
32
20
  return fetch(isSolrCsv(_url) ? _url : url.href).then(response => {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Get unique values from a column in a dataset
3
+ * @returns {Array} - The unique values
4
+ */
5
+ export const getUniqueValues = (data: Array<Record<string, any>>, columnName: string) => {
6
+ let result = {}
7
+
8
+ for (let i = 0; i < data.length; i++) {
9
+ let val = data[i][columnName]
10
+
11
+ if (undefined === val) continue
12
+
13
+ if (undefined === result[val]) {
14
+ result[val] = true
15
+ }
16
+ }
17
+
18
+ return Object.keys(result)
19
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Hashes an object
3
+ * @param {Object} row - The object to hash
4
+ * @returns {number} - The hash of the object
5
+ */
6
+ export const hashObj = row => {
7
+ try {
8
+ if (!row || row === undefined) return null
9
+
10
+ let str = JSON.stringify(row)
11
+ let hash = 0
12
+
13
+ if (str.length === 0) return hash
14
+
15
+ for (let i = 0; i < str.length; i++) {
16
+ let char = str.charCodeAt(i)
17
+ hash = (hash << 5) - hash + char
18
+ hash = hash & hash
19
+ }
20
+
21
+ return hash
22
+ } catch (e) {
23
+ console.error({ state: 'COVE: ' + e.message }) // eslint-disable-line
24
+ }
25
+ }
@@ -12,6 +12,11 @@ export default function isRightAlignedTableValue(value = '') {
12
12
  if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(value)) {
13
13
  return false
14
14
  }
15
+ // Years like 1995 and 2014 are not considered numbers
16
+ if (/^(19|20)\d{2}$/.test(value)) {
17
+ return false
18
+ }
19
+
15
20
  return numericStrings.includes(value) || /^[\$\d\.\%\,\-\s\(\)CI<>]*$/.test(value)
16
21
  }
17
22
  return false
@@ -1,15 +1,16 @@
1
1
  import _ from 'lodash'
2
2
  import { MarkupVariable, MarkupCondition } from '../types/MarkupVariable'
3
3
  import { VizFilter } from '../types/VizFilter'
4
+ import { Datasets } from '../types/DataSet'
4
5
  import { filterVizData } from './filterVizData'
5
6
 
6
7
  /**
7
8
  * Replaces {{variable}} tags in content with actual data values.
8
9
  *
9
10
  * @param content - Content string with markup variables
10
- * @param data - Dataset to extract values from
11
+ * @param data - Dataset to extract values from (for backward compatibility)
11
12
  * @param markupVariables - Variable configurations
12
- * @param options - isEditor, showNoDataMessage, allowHideSection, filters
13
+ * @param options - isEditor, showNoDataMessage, allowHideSection, filters, datasets
13
14
  * @returns Processed content and state flags
14
15
  *
15
16
  * @security Returns plain text - must be parsed with html-react-parser before rendering
@@ -23,13 +24,25 @@ export const processMarkupVariables = (
23
24
  showNoDataMessage?: boolean
24
25
  allowHideSection?: boolean
25
26
  filters?: VizFilter[]
27
+ datasets?: Datasets
28
+ configDataKey?: string // Add support for widget's assigned dataset
26
29
  } = {}
27
30
  ): {
28
31
  processedContent: string
29
32
  shouldHideSection: boolean
30
33
  shouldShowNoDataMessage: boolean
31
34
  } => {
32
- const { isEditor = false, showNoDataMessage = false, allowHideSection = false, filters = [] } = options
35
+ const { isEditor = false, showNoDataMessage = false, allowHideSection = false, filters = [], datasets, configDataKey } = options
36
+
37
+ // Helper function to get data for a specific variable
38
+ const getDataForVariable = (variable: MarkupVariable): any[] => {
39
+ // If data prop is empty, try to use the widget's assigned dataset
40
+ if ((!data || data.length === 0) && configDataKey && datasets && datasets[configDataKey]) {
41
+ return datasets[configDataKey].data || []
42
+ }
43
+
44
+ return data || []
45
+ }
33
46
 
34
47
  // Early return for invalid inputs
35
48
  if (_.isEmpty(markupVariables) || !content) {
@@ -40,12 +53,6 @@ export const processMarkupVariables = (
40
53
  }
41
54
  }
42
55
 
43
- // Apply filters to data if filters are present
44
- let workingData = data
45
- if (filters && filters.length > 0) {
46
- workingData = filterVizData(filters, data)
47
- }
48
-
49
56
  try {
50
57
  const emptyVariableChecker: boolean[] = []
51
58
  const noDataMessageChecker: boolean[] = []
@@ -64,11 +71,19 @@ export const processMarkupVariables = (
64
71
  return variableTag
65
72
  }
66
73
 
74
+ // Get the appropriate dataset for this variable
75
+ let variableData = getDataForVariable(workingVariable)
76
+
77
+ // Apply global filters if present
78
+ if (filters && filters.length > 0) {
79
+ variableData = filterVizData(filters, variableData)
80
+ }
81
+
67
82
  // Filter data with error handling (apply conditions on top of already filtered data)
68
83
  const conditionFilteredData =
69
84
  workingVariable.conditions.length === 0
70
- ? workingData
71
- : filterDataByConditions(workingData, [...workingVariable.conditions])
85
+ ? variableData
86
+ : filterDataByConditions(variableData, [...workingVariable.conditions])
72
87
 
73
88
  // Extract values with error handling
74
89
  const variableValues: string[] = _.uniq(
@@ -202,4 +217,4 @@ export const validateMarkupVariables = (
202
217
  })
203
218
 
204
219
  return errors
205
- }
220
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Merges new filter values with existing custom ordered values
3
+ *
4
+ * When order === 'cust', this function ensures that:
5
+ * 1. New values from the data are appended to the end of orderedValues
6
+ * 2. If orderedValues is missing/empty, it initializes with current values
7
+ *
8
+ * @param currentValues - Array of all unique values extracted from current data
9
+ * @param existingOrderedValues - Existing custom ordered values array (may be undefined/empty)
10
+ * @param order - The filter's order setting
11
+ * @returns Updated orderedValues array with new values appended, or undefined if not custom order
12
+ */
13
+ export const mergeCustomOrderValues = (
14
+ currentValues: (string | number)[],
15
+ existingOrderedValues: string[] | undefined,
16
+ order: string | undefined
17
+ ): string[] | undefined => {
18
+ // Only process for custom order
19
+ if (order !== 'cust') {
20
+ return existingOrderedValues
21
+ }
22
+
23
+ // Normalize current values to strings (filter values are always displayed as strings)
24
+ const normalizedCurrentValues = currentValues.map(v => String(v))
25
+
26
+ // If orderedValues doesn't exist or is empty, initialize with current values
27
+ if (!existingOrderedValues || existingOrderedValues.length === 0) {
28
+ return [...normalizedCurrentValues]
29
+ }
30
+
31
+ // Find new values that aren't in orderedValues yet
32
+ const orderedValuesSet = new Set(existingOrderedValues)
33
+ const newValues = normalizedCurrentValues.filter(value => !orderedValuesSet.has(value))
34
+
35
+ // Return merged array: existing order + new values appended
36
+ return [...existingOrderedValues, ...newValues]
37
+ }
@@ -68,6 +68,7 @@ export const publishAnalyticsEvent = <T extends ANALYTICS_EVENT_TYPES>({
68
68
  // Format: APP|VIZTYPE_VIZSUBTYPE|VIZ_TITLE|INTERACTION_EVENT_NAME|INTERACTION_TYPE|SPECIFICS
69
69
  const vizTypeSubType = vizSubType ? `${vizType}_${vizSubType}` : vizType
70
70
  const formattedEvent = `${app}|${vizTypeSubType}|${vizTitle || 'unknown'}|${eventType}|${eventAction}|${specifics || 'no details'}`
71
+
71
72
  return publish('cove:analytics', {
72
73
  formattedEvent,
73
74
  eventLabel
@@ -0,0 +1,65 @@
1
+ import Papa from 'papaparse'
2
+
3
+ /**
4
+ * Parses CSV text while preserving newlines and commas within quoted fields.
5
+ *
6
+ * @param responseText - The raw CSV text to parse
7
+ * @param options - Parsing options
8
+ * @param options.delimiter - The delimiter to use after processing (default: '|')
9
+ * @param options.dynamicTyping - Whether to automatically convert types (default: false)
10
+ * @returns Parsed CSV data as an array of objects
11
+ */
12
+ export function parseCsvWithQuotes(
13
+ responseText: string,
14
+ options: {
15
+ delimiter?: string
16
+ dynamicTyping?: boolean
17
+ } = {}
18
+ ): any[] {
19
+ const { delimiter = '|', dynamicTyping = false } = options
20
+
21
+ const NEWLINE_PLACEHOLDER = '__COVE_NEWLINE__'
22
+
23
+ // Preserve newlines in quoted fields by replacing with placeholder
24
+ const quotedFields: string[] = []
25
+ let placeholderIndex = 0
26
+ let sanitizedText = responseText.replace(/("(?:[^"\\]|\\.|[\s\S])*?")/g, (match) => {
27
+ const preserved = match.replace(/\n/g, NEWLINE_PLACEHOLDER)
28
+ quotedFields.push(preserved)
29
+ return `__QUOTED_FIELD_${placeholderIndex++}__`
30
+ })
31
+
32
+ // Replace commas outside quoted fields with pipe delimiter
33
+ 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)
39
+ })
40
+
41
+ // Parse with Papa.parse
42
+ const parsedCsv = Papa.parse(sanitizedText, {
43
+ header: true,
44
+ skipEmptyLines: true,
45
+ delimiter,
46
+ dynamicTyping
47
+ })
48
+
49
+ // Restore newlines in parsed data
50
+ const restoredData = parsedCsv.data.map((row: any) => {
51
+ const restoredRow: any = {}
52
+ Object.keys(row).forEach(key => {
53
+ const value = row[key]
54
+ if (typeof value === 'string') {
55
+ restoredRow[key] = value.replace(new RegExp(NEWLINE_PLACEHOLDER, 'g'), '\n')
56
+ } else {
57
+ restoredRow[key] = value
58
+ }
59
+ })
60
+ return restoredRow
61
+ })
62
+
63
+ return restoredData
64
+ }
65
+
@@ -1,6 +1,6 @@
1
1
  import _ from 'lodash'
2
2
 
3
- const getColumns = (data: Record<string, any>[], columnName: string, pivot: string[], excludeColumns: string[]) => {
3
+ const getNonPivotColumns = (data: Record<string, any>[], columnName: string, pivot: string[], excludeColumns: string[]) => {
4
4
  const excludedColumns = [columnName, ...pivot, ...excludeColumns]
5
5
  return _.uniq(data.flatMap(row => Object.keys(row))).filter(col => !excludedColumns.includes(col))
6
6
  }
@@ -13,7 +13,7 @@ export const pivotData = (
13
13
  pivot: string[],
14
14
  excludeColumns: string[]
15
15
  ): Record<string, any>[] => {
16
- const columns = getColumns(data, columnName, pivot, excludeColumns)
16
+ const columns = getNonPivotColumns(data, columnName, pivot, excludeColumns)
17
17
  const newColumns = data.reduce((acc, row) => {
18
18
  acc[row[columnName]] = ''
19
19
  return acc
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Helpers for preparing screenshot containers for image downloads
3
+ */
4
+
5
+ export interface ClonedElements {
6
+ clonedTree: HTMLElement
7
+ clonedViz: HTMLElement
8
+ }
9
+
10
+ interface VisualizationWrapper {
11
+ parentChildren: Element[]
12
+ wrapperIndex: number
13
+ }
14
+
15
+ /**
16
+ * Walk up from viz to find a parent container that has:
17
+ * - Multiple children
18
+ * - At least one child (before viz) that contains H2/H3 but NOT the viz
19
+ *
20
+ * Stops climbing at section boundaries (never goes above <section> or .dfe-section)
21
+ */
22
+ function findParentWithContext(viz: HTMLElement): HTMLElement | null {
23
+ let current = viz.parentElement
24
+
25
+ while (current) {
26
+ const isSection = current.tagName === 'SECTION' || current.classList.contains('dfe-section')
27
+
28
+ const children = Array.from(current.children)
29
+
30
+ // Need at least 2 children (heading + viz wrapper)
31
+ if (children.length < 2) {
32
+ if (isSection) {
33
+ return null
34
+ }
35
+ current = current.parentElement
36
+ continue
37
+ }
38
+
39
+ // Find which child contains the viz (guaranteed to exist since we're walking up from viz)
40
+ const vizChildIndex = children.findIndex(child => child.contains(viz))
41
+
42
+ // Check children before the viz for heading
43
+ for (let i = 0; i < vizChildIndex; i++) {
44
+ const child = children[i] as HTMLElement
45
+ const isHeading = child.tagName === 'H2' || child.tagName === 'H3'
46
+ const containsHeading = child.querySelector('h2, h3') !== null
47
+
48
+ if (isHeading || containsHeading) {
49
+ return current
50
+ }
51
+ }
52
+
53
+ // Don't climb above section boundaries
54
+ if (isSection) {
55
+ return null
56
+ }
57
+
58
+ // No suitable heading found in this parent's children, go up
59
+ current = current.parentElement
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Find which direct child of the parent container contains the visualization
67
+ */
68
+ function findVisualizationWrapper(viz: HTMLElement, parent: Element): VisualizationWrapper {
69
+ const parentChildren = Array.from(parent.children)
70
+ const wrapperIndex = parentChildren.findIndex(child => child.contains(viz))
71
+ return { parentChildren, wrapperIndex }
72
+ }
73
+
74
+ /**
75
+ * Walk backwards from viz to find nearest H2 or H3
76
+ * Stops early if another visualization is encountered
77
+ */
78
+ function findNearestHeadingIndex(parentChildren: Element[], vizWrapperIndex: number): number {
79
+ for (let i = vizWrapperIndex - 1; i >= 0; i--) {
80
+ const child = parentChildren[i] as HTMLElement
81
+
82
+ // STOP: Another visualization found - don't include its content
83
+ if (child.classList.contains('cdc-open-viz-module') || child.querySelector('.cdc-open-viz-module')) {
84
+ return -1
85
+ }
86
+
87
+ // Found direct H2/H3
88
+ if (child.tagName === 'H2' || child.tagName === 'H3') {
89
+ return i
90
+ }
91
+
92
+ // Found nested H2/H3
93
+ if (child.querySelector('h2, h3')) {
94
+ return i
95
+ }
96
+ }
97
+
98
+ return -1 // No heading found
99
+ }
100
+
101
+ /**
102
+ * Build cloned parent container with only selected children (heading through viz)
103
+ */
104
+ function buildContextClone(
105
+ parent: Element,
106
+ headingIndex: number,
107
+ vizWrapperIndex: number,
108
+ elementToCapture: string
109
+ ): ClonedElements {
110
+ const parentChildren = Array.from(parent.children)
111
+
112
+ // Shallow clone parent (preserves classes/styling)
113
+ const clonedTree = parent.cloneNode(false) as HTMLElement
114
+
115
+ // Append only selected children (heading → viz wrapper)
116
+ for (let i = headingIndex; i <= vizWrapperIndex; i++) {
117
+ clonedTree.appendChild(parentChildren[i].cloneNode(true))
118
+ }
119
+
120
+ // Find viz in cloned tree
121
+ const clonedViz = clonedTree.querySelector(`[data-download-id="${elementToCapture}"]`) as HTMLElement
122
+
123
+ // Container styling adjustments
124
+ clonedTree.style.marginBottom = '0'
125
+
126
+ // Remove top margin from context headings (not inside viz)
127
+ const allHeadings = clonedTree.querySelectorAll('h2, h3')
128
+ allHeadings.forEach(heading => {
129
+ if (!clonedViz.contains(heading)) {
130
+ ;(heading as HTMLElement).style.marginTop = '0'
131
+ }
132
+ })
133
+
134
+ return { clonedTree, clonedViz }
135
+ }
136
+
137
+ function isInEditorMode(element: HTMLElement): boolean {
138
+ return element.closest('.cdc-open-viz-module.isEditor') !== null
139
+ }
140
+
141
+ /**
142
+ * Prepare cloned elements (with or without context)
143
+ * Returns viz-only clone as fallback for any failure
144
+ * Exported for testing
145
+ */
146
+ export function prepareClonedElements(
147
+ baseSvg: HTMLElement,
148
+ includeContextInDownload: boolean,
149
+ elementToCapture: string
150
+ ): ClonedElements {
151
+ const defaultClone = (): ClonedElements => {
152
+ const tree = baseSvg.cloneNode(true) as HTMLElement
153
+ return { clonedTree: tree, clonedViz: tree }
154
+ }
155
+
156
+ // Early return: context not requested or in editor mode (editor downloads should not include context)
157
+ if (!includeContextInDownload || isInEditorMode(baseSvg)) {
158
+ return defaultClone()
159
+ }
160
+
161
+ // Early return: no parent with context found
162
+ const parent = findParentWithContext(baseSvg)
163
+ if (!parent) {
164
+ return defaultClone()
165
+ }
166
+
167
+ // Get viz wrapper (guaranteed to exist since findParentWithContext found it)
168
+ const { parentChildren, wrapperIndex } = findVisualizationWrapper(baseSvg, parent as Element)
169
+
170
+ // Early return: no heading found (or another viz in the way)
171
+ const headingIndex = findNearestHeadingIndex(parentChildren, wrapperIndex)
172
+ if (headingIndex < 0) {
173
+ return defaultClone()
174
+ }
175
+
176
+ // Build with context
177
+ return buildContextClone(parent, headingIndex, wrapperIndex, elementToCapture)
178
+ }
179
+
180
+ /**
181
+ * Strip all <a> tags from cloned tree (links aren't clickable in PNG)
182
+ */
183
+ function stripLinks(clonedTree: HTMLElement): void {
184
+ const allLinks = clonedTree.querySelectorAll('a')
185
+ allLinks.forEach(link => {
186
+ const textNode = document.createTextNode(link.textContent || '')
187
+ link.parentNode?.replaceChild(textNode, link)
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Convert canvas elements to images (canvas pixel data doesn't clone)
193
+ */
194
+ function convertCanvasToImages(baseSvg: HTMLElement, clonedViz: HTMLElement): void {
195
+ const originalCanvases = baseSvg.querySelectorAll('canvas')
196
+ const clonedCanvases = clonedViz.querySelectorAll('canvas')
197
+
198
+ clonedCanvases.forEach((clonedCanvas, index) => {
199
+ const originalCanvas = originalCanvases[index] as HTMLCanvasElement
200
+ if (originalCanvas && originalCanvas.width > 0 && originalCanvas.height > 0) {
201
+ const img = document.createElement('img')
202
+ img.src = originalCanvas.toDataURL('image/png')
203
+ img.width = originalCanvas.width
204
+ img.height = originalCanvas.height
205
+ img.className = originalCanvas.className
206
+ clonedCanvas.parentNode?.replaceChild(img, clonedCanvas)
207
+ }
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Expand SVG widths and remove animation classes
213
+ */
214
+ function expandSvgWidths(clonedViz: HTMLElement): void {
215
+ const svgWidthBuffer = 25
216
+ const svgElements = clonedViz.querySelectorAll('svg')
217
+
218
+ svgElements.forEach(svg => {
219
+ const currentWidth = parseInt(svg.getAttribute('width') || '0')
220
+ if (currentWidth > 0) {
221
+ svg.setAttribute('width', (currentWidth + svgWidthBuffer).toString())
222
+ }
223
+
224
+ // Remove animation classes to show final state immediately
225
+ svg.classList.remove('animated', 'animate')
226
+ })
227
+ }
228
+
229
+ /**
230
+ * Main function: Prepare a complete container ready for html2canvas screenshot
231
+ */
232
+ export function prepareScreenshotContainer(
233
+ baseSvg: HTMLElement,
234
+ includeContextInDownload: boolean,
235
+ elementToCapture: string
236
+ ): HTMLDivElement {
237
+ // 1. Clone elements (with or without context)
238
+ const { clonedTree, clonedViz } = prepareClonedElements(baseSvg, includeContextInDownload, elementToCapture)
239
+
240
+ // 2. Strip all links (not clickable in static image)
241
+ stripLinks(clonedTree)
242
+
243
+ // 3. Convert canvas elements to images
244
+ convertCanvasToImages(baseSvg, clonedViz)
245
+
246
+ // 4. Expand SVG widths to prevent clipping
247
+ expandSvgWidths(clonedViz)
248
+
249
+ // 5. Calculate viz dimensions
250
+ const computedStyle = getComputedStyle(baseSvg)
251
+ const vizWidth =
252
+ parseFloat(computedStyle.width) -
253
+ (parseFloat(computedStyle.paddingLeft) || 0) -
254
+ (parseFloat(computedStyle.paddingRight) || 0)
255
+
256
+ // 6. Create and style container
257
+ const container = document.createElement('div')
258
+ container.style.width = `${vizWidth + 36}px`
259
+ container.style.padding = '18px'
260
+
261
+ // 7. Reset viz padding
262
+ clonedViz.style.padding = '0'
263
+
264
+ // 8. Append cloned tree to container
265
+ container.appendChild(clonedTree)
266
+
267
+ return container
268
+ }