@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.
- package/_stories/Gallery.Charts.stories.tsx +307 -0
- package/_stories/Gallery.DataBite.stories.tsx +72 -0
- package/_stories/Gallery.Maps.stories.tsx +230 -0
- package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
- package/_stories/PageART.stories.tsx +192 -0
- package/_stories/PageBRFSS.stories.tsx +289 -0
- package/_stories/PageCancerRegistries.stories.tsx +199 -0
- package/_stories/PageEasternEquineEncephalitis.stories.tsx +202 -0
- package/_stories/PageExcessiveAlcoholUse.stories.tsx +196 -0
- package/_stories/PageMaternalMortality.stories.tsx +192 -0
- package/_stories/PageOralHealth.stories.tsx +196 -0
- package/_stories/PageRespiratory.stories.tsx +332 -0
- package/_stories/PageSmokingTobacco.stories.tsx +195 -0
- package/_stories/PageStateDiabetesProfiles.stories.tsx +196 -0
- package/_stories/PageWastewater.stories.tsx +463 -0
- package/_stories/StoryRenderingTests.stories.tsx +164 -0
- package/assets/icon-magnifying-glass.svg +5 -0
- package/assets/icon-warming-stripes.svg +13 -0
- package/components/AdvancedEditor/AdvancedEditor.tsx +7 -1
- package/components/AdvancedEditor/EmbedEditor.tsx +281 -0
- package/components/ComboBox/ComboBox.tsx +345 -0
- package/components/ComboBox/combobox.styles.css +185 -0
- package/components/ComboBox/index.ts +1 -0
- package/components/CustomColorsEditor/CustomColorsEditor.css +299 -0
- package/components/CustomColorsEditor/CustomColorsEditor.tsx +209 -0
- package/components/CustomColorsEditor/index.ts +1 -0
- package/components/DataTable/DataTable.tsx +132 -58
- package/components/DataTable/DataTableStandAlone.tsx +8 -3
- package/components/DataTable/components/DataTableEditorPanel.tsx +12 -2
- package/components/DataTable/data-table.css +217 -210
- package/components/DataTable/helpers/mapCellMatrix.tsx +28 -9
- package/components/DataTable/helpers/standardizeState.js +2 -2
- package/components/DataTable/helpers/tests/standardizeState.test.js +54 -0
- package/components/EditorPanel/ColumnsEditor.tsx +37 -19
- package/components/EditorPanel/DataTableEditor.tsx +54 -28
- package/components/EditorPanel/EditorPanel.styles.css +439 -0
- package/components/EditorPanel/EditorPanel.tsx +144 -0
- package/components/EditorPanel/EditorPanelDispatch.tsx +75 -0
- package/components/EditorPanel/FieldSetWrapper.tsx +66 -23
- package/components/EditorPanel/FootnotesEditor.tsx +44 -37
- package/components/EditorPanel/Inputs.tsx +44 -8
- package/components/EditorPanel/VizFilterEditor/NestedDropdownEditor.tsx +35 -62
- package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +246 -175
- package/components/EditorPanel/components/MarkupVariablesEditor.tsx +61 -22
- package/components/EditorPanel/sections/VisualSection.tsx +169 -0
- package/components/Filters/Filters.tsx +57 -10
- package/components/Filters/components/Dropdown.tsx +6 -1
- package/components/Filters/helpers/getNestedOptions.ts +2 -1
- package/components/Filters/helpers/handleSorting.ts +1 -1
- package/components/Footnotes/Footnotes.tsx +35 -25
- package/components/Footnotes/FootnotesStandAlone.tsx +42 -6
- package/components/HeaderThemeSelector/HeaderThemeSelector.css +43 -0
- package/components/HeaderThemeSelector/HeaderThemeSelector.stories.tsx +74 -0
- package/components/HeaderThemeSelector/HeaderThemeSelector.tsx +61 -0
- package/components/HeaderThemeSelector/index.ts +2 -0
- package/components/Layout/components/Sidebar/components/sidebar.styles.scss +82 -0
- package/components/Layout/components/Visualization/index.tsx +16 -1
- package/components/Layout/components/Visualization/visualizations.scss +7 -0
- package/components/Layout/styles/editor.scss +2 -1
- package/components/Legend/Legend.Gradient.tsx +1 -1
- package/components/Loader/Loader.tsx +1 -1
- package/components/MediaControls.tsx +63 -34
- package/components/PaletteConversionModal.tsx +7 -4
- package/components/PaletteSelector/PaletteSelector.css +49 -6
- package/components/Table/components/Cell.tsx +23 -2
- package/components/Table/components/Row.tsx +5 -3
- package/components/_stories/Filters.stories.tsx +20 -1
- package/components/_stories/Footnotes.CSV.stories.tsx +247 -0
- package/components/_stories/Footnotes.stories.tsx +768 -3
- package/components/_stories/Inputs.stories.tsx +2 -2
- package/components/_stories/styles.scss +0 -1
- package/components/ui/Accordion.jsx +1 -1
- package/components/ui/Icon.tsx +3 -1
- package/components/ui/Title/index.tsx +30 -2
- package/components/ui/Title/title.styles.css +42 -0
- package/components/ui/accordion.styles.css +57 -0
- package/data/chartColorPalettes.ts +1 -1
- package/dist/cove-main.css +75 -6
- package/dist/cove-main.css.map +1 -1
- package/generateViteConfig.js +8 -1
- package/helpers/addValuesToFilters.ts +11 -1
- package/helpers/constants.ts +37 -0
- package/helpers/cove/number.ts +33 -12
- package/helpers/coveUpdateWorker.ts +20 -11
- package/helpers/embedCodeGenerator.ts +109 -0
- package/helpers/fetchRemoteData.ts +3 -15
- package/helpers/getUniqueValues.ts +19 -0
- package/helpers/hashObj.ts +25 -0
- package/helpers/isRightAlignedTableValue.js +5 -0
- package/helpers/markupProcessor.ts +27 -12
- package/helpers/mergeCustomOrderValues.ts +37 -0
- package/helpers/metrics/helpers.ts +1 -0
- package/helpers/parseCsvWithQuotes.ts +65 -0
- package/helpers/pivotData.ts +2 -2
- package/helpers/prepareScreenshot.ts +268 -0
- package/helpers/queryStringUtils.ts +29 -0
- package/helpers/testing.ts +17 -4
- package/helpers/tests/prepareScreenshot.test.ts +414 -0
- package/helpers/tests/queryStringUtils.test.ts +381 -0
- package/helpers/tests/testStandaloneBuild.ts +23 -5
- package/helpers/useDataVizClasses.ts +0 -1
- package/helpers/ver/4.25.11.ts +13 -0
- package/helpers/ver/4.26.1.ts +80 -0
- package/helpers/viewports.ts +2 -0
- package/hooks/useDataColumns.ts +63 -0
- package/hooks/useFilterManagement.ts +94 -0
- package/hooks/useLegendSeparators.ts +26 -0
- package/hooks/useListManagement.ts +192 -0
- package/package.json +6 -4
- package/styles/_button-section.scss +0 -3
- package/styles/_common-components.css +73 -0
- package/styles/_global.scss +25 -5
- package/styles/base.scss +0 -50
- package/styles/cove-main.scss +3 -1
- package/styles/filters.scss +10 -3
- package/styles/v2/base/index.scss +0 -1
- package/styles/v2/components/editor.scss +14 -6
- package/styles/v2/utils/_breakpoints.scss +1 -1
- package/styles/v2/utils/index.scss +0 -1
- package/styles/waiting.scss +1 -1
- package/types/Axis.ts +1 -0
- package/types/ForecastingSeriesKey.ts +1 -0
- package/types/MarkupInclude.ts +5 -3
- package/types/MarkupVariable.ts +1 -1
- package/types/Series.ts +3 -0
- package/types/Table.ts +1 -0
- package/types/Visualization.ts +1 -0
- package/types/VizFilter.ts +2 -0
- package/LICENSE +0 -201
- package/styles/_mixins.scss +0 -13
- package/styles/_typography.scss +0 -0
- package/styles/v2/base/_typography.scss +0 -0
- package/styles/v2/components/guidance-block.scss +0 -74
- 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
|
-
|
|
15
|
-
|
|
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
|
-
?
|
|
71
|
-
: filterDataByConditions(
|
|
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
|
+
|
package/helpers/pivotData.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import _ from 'lodash'
|
|
2
2
|
|
|
3
|
-
const
|
|
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 =
|
|
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
|
+
}
|