@cdc/core 4.25.8 → 4.25.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/_stories/StoryRenderingTests.stories.tsx +164 -0
  2. package/components/AdvancedEditor/AdvancedEditor.tsx +32 -9
  3. package/components/CustomColorsEditor/CustomColorsEditor.css +299 -0
  4. package/components/CustomColorsEditor/CustomColorsEditor.tsx +209 -0
  5. package/components/CustomColorsEditor/index.ts +1 -0
  6. package/components/DataTable/DataTable.tsx +56 -38
  7. package/components/DataTable/DataTableStandAlone.tsx +8 -3
  8. package/components/DataTable/components/ChartHeader.tsx +44 -14
  9. package/components/DataTable/components/DataTableEditorPanel.tsx +12 -2
  10. package/components/DataTable/components/ExpandCollapse.tsx +10 -1
  11. package/components/DataTable/components/MapHeader.tsx +24 -13
  12. package/components/DataTable/data-table.css +12 -0
  13. package/components/DataTable/helpers/chartCellMatrix.tsx +11 -8
  14. package/components/DataTable/helpers/mapCellMatrix.tsx +33 -4
  15. package/components/DataTable/helpers/standardizeState.js +2 -2
  16. package/components/DataTable/helpers/tests/standardizeState.test.js +54 -0
  17. package/components/DownloadButton.tsx +40 -14
  18. package/components/EditorPanel/DataTableEditor.tsx +3 -3
  19. package/components/EditorPanel/EditorPanel.styles.css +423 -0
  20. package/components/EditorPanel/FootnotesEditor.tsx +44 -37
  21. package/components/EditorPanel/Inputs.tsx +12 -2
  22. package/components/EditorPanel/VizFilterEditor/NestedDropdownEditor.tsx +35 -62
  23. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +12 -2
  24. package/components/EditorPanel/components/MarkupHighlightedTextField.tsx +227 -0
  25. package/components/EditorPanel/components/MarkupVariablesEditor.tsx +450 -0
  26. package/components/EditorPanel/components/PanelMarkup.tsx +59 -0
  27. package/components/ErrorBoundary.jsx +3 -1
  28. package/components/Filters/Filters.tsx +52 -24
  29. package/components/Filters/components/Dropdown.tsx +6 -1
  30. package/components/Filters/components/Tabs.tsx +1 -0
  31. package/components/Footnotes/Footnotes.tsx +35 -25
  32. package/components/Footnotes/FootnotesStandAlone.tsx +42 -6
  33. package/components/HeaderThemeSelector/HeaderThemeSelector.css +43 -0
  34. package/components/HeaderThemeSelector/HeaderThemeSelector.stories.tsx +74 -0
  35. package/components/HeaderThemeSelector/HeaderThemeSelector.tsx +61 -0
  36. package/components/HeaderThemeSelector/index.ts +2 -0
  37. package/components/Layout/styles/editor.scss +2 -1
  38. package/components/Legend/Legend.Gradient.tsx +3 -6
  39. package/components/LegendShape.tsx +121 -3
  40. package/components/Loader/Loader.tsx +1 -1
  41. package/components/MediaControls.tsx +72 -21
  42. package/components/PaletteConversionModal.tsx +90 -0
  43. package/components/PaletteSelector/DeveloperPaletteRollback.tsx +114 -0
  44. package/components/PaletteSelector/PaletteSelector.css +94 -0
  45. package/components/PaletteSelector/PaletteSelector.tsx +112 -0
  46. package/components/PaletteSelector/index.ts +2 -0
  47. package/components/RichTooltip/RichTooltip.tsx +1 -0
  48. package/components/Table/Table.tsx +3 -1
  49. package/components/Table/components/Cell.tsx +23 -2
  50. package/components/Table/components/Row.tsx +5 -3
  51. package/components/_stories/BlurStrokeTest.stories.tsx +1 -1
  52. package/components/_stories/DataTable.stories.tsx +1 -1
  53. package/components/_stories/Filters.stories.tsx +21 -2
  54. package/components/_stories/Footnotes.CSV.stories.tsx +247 -0
  55. package/components/_stories/Footnotes.stories.tsx +769 -4
  56. package/components/_stories/Inputs.stories.tsx +3 -3
  57. package/components/_stories/MultiSelect.stories.tsx +3 -3
  58. package/components/_stories/NestedDropdown.stories.tsx +1 -1
  59. package/components/_stories/Table.stories.tsx +1 -1
  60. package/components/_stories/styles.scss +0 -1
  61. package/components/elements/_stories/Button.stories.tsx +1 -1
  62. package/components/elements/_stories/Card.stories.tsx +1 -1
  63. package/components/inputs/InputToggle.tsx +2 -0
  64. package/components/managers/DataDesigner.tsx +10 -9
  65. package/components/managers/_stories/DataDesigner.stories.tsx +1 -1
  66. package/components/ui/Accordion.jsx +1 -1
  67. package/components/ui/Tooltip.tsx +2 -1
  68. package/components/ui/_stories/Accordion.stories.tsx +1 -1
  69. package/components/ui/_stories/ColorPaletteMigration.stories.mdx +275 -0
  70. package/components/ui/_stories/Colors.stories.tsx +330 -0
  71. package/components/ui/_stories/IconGallery.stories.tsx +316 -0
  72. package/components/ui/_stories/Title.stories.tsx +1 -1
  73. package/components/ui/accordion.styles.css +57 -0
  74. package/contexts/EditorContext.ts +18 -0
  75. package/contexts/editor.actions.ts +28 -0
  76. package/contexts/editor.reducer.ts +94 -0
  77. package/data/chartColorPalettes.ts +118 -0
  78. package/data/colorPalettes.ts +9 -0
  79. package/data/mapColorPalettes.ts +45 -0
  80. package/data/sharedPalettes.ts +50 -0
  81. package/dist/cove-main.css +63 -14
  82. package/dist/cove-main.css.map +1 -1
  83. package/generateViteConfig.js +80 -0
  84. package/helpers/addValuesToFilters.ts +7 -3
  85. package/helpers/cloneConfig.ts +31 -0
  86. package/helpers/configDataHelpers.ts +128 -0
  87. package/helpers/configHelpers.ts +27 -0
  88. package/helpers/constants.ts +42 -2
  89. package/helpers/cove/number.ts +33 -12
  90. package/helpers/coveUpdateWorker.ts +15 -3
  91. package/helpers/fetchRemoteData.ts +3 -15
  92. package/helpers/filterColorPalettes.ts +152 -0
  93. package/helpers/generateColorsArray.ts +13 -0
  94. package/helpers/getColorPaletteVersion.ts +33 -0
  95. package/helpers/getPaletteAccessor.ts +18 -0
  96. package/helpers/markupProcessor.ts +220 -0
  97. package/helpers/mergeCustomOrderValues.ts +37 -0
  98. package/helpers/metrics/helpers.ts +42 -19
  99. package/helpers/metrics/types.ts +48 -9
  100. package/helpers/metrics/utils.ts +34 -0
  101. package/helpers/palettes/colorDistributions.ts +56 -0
  102. package/helpers/palettes/migratePaletteName.ts +150 -0
  103. package/helpers/palettes/standardizePaletteNames.ts +77 -0
  104. package/helpers/palettes/utils.ts +267 -0
  105. package/helpers/parseCsvWithQuotes.ts +65 -0
  106. package/helpers/queryStringUtils.ts +13 -0
  107. package/helpers/testing.ts +358 -0
  108. package/helpers/tests/addValuesToFilters.test.ts +1 -2
  109. package/helpers/tests/generateColorsArray.test.ts +24 -0
  110. package/helpers/tests/markupProcessor.test.ts +538 -0
  111. package/helpers/tests/testStandaloneBuild.ts +44 -0
  112. package/helpers/useMarkupVariables.ts +31 -0
  113. package/helpers/vegaConfig.ts +0 -1
  114. package/helpers/ver/4.24.10.ts +2 -1
  115. package/helpers/ver/4.24.11.ts +2 -1
  116. package/helpers/ver/4.24.3.ts +2 -1
  117. package/helpers/ver/4.24.4.ts +2 -1
  118. package/helpers/ver/4.24.5.ts +2 -1
  119. package/helpers/ver/4.24.7.ts +2 -1
  120. package/helpers/ver/4.24.9.ts +2 -1
  121. package/helpers/ver/4.25.1.ts +2 -1
  122. package/helpers/ver/4.25.10.ts +36 -0
  123. package/helpers/ver/4.25.11.ts +13 -0
  124. package/helpers/ver/4.25.3.ts +2 -1
  125. package/helpers/ver/4.25.4.ts +2 -1
  126. package/helpers/ver/4.25.6.ts +2 -1
  127. package/helpers/ver/4.25.7.ts +2 -1
  128. package/helpers/ver/4.25.8.ts +2 -1
  129. package/helpers/ver/4.25.9.ts +293 -0
  130. package/helpers/ver/tests/4.25.10.test.ts +204 -0
  131. package/helpers/ver/tests/4.25.8.test.ts +1 -1
  132. package/helpers/ver/tests/4.25.9.test.ts +51 -0
  133. package/helpers/viewports.ts +2 -0
  134. package/hooks/useColorPalette.ts +79 -0
  135. package/package.json +13 -4
  136. package/styles/_common-components.css +73 -0
  137. package/styles/_global.scss +32 -10
  138. package/styles/base.scss +8 -55
  139. package/styles/cove-main.scss +3 -1
  140. package/styles/filters.scss +10 -3
  141. package/styles/v2/base/index.scss +0 -1
  142. package/styles/v2/components/button.scss +4 -3
  143. package/styles/v2/components/editor.scss +16 -7
  144. package/styles/v2/layout/_data-table.scss +3 -2
  145. package/styles/v2/themes/_color-definitions.scss +18 -17
  146. package/styles/v2/utils/_breakpoints.scss +1 -1
  147. package/styles/v2/utils/index.scss +0 -1
  148. package/styles/waiting.scss +1 -1
  149. package/testing-setup.js +32 -0
  150. package/types/MarkupInclude.ts +8 -2
  151. package/types/MarkupVariable.ts +19 -0
  152. package/types/VizFilter.ts +2 -0
  153. package/vitest.config.ts +16 -0
  154. package/components/ui/_stories/Colors.stories.mdx +0 -220
  155. package/components/ui/_stories/IconGallery.stories.mdx +0 -14
  156. package/data/colorPalettes.js +0 -171
  157. package/helpers/formatConfigBeforeSave.ts +0 -135
  158. package/helpers/tests/formatConfigBeforeSave.test.ts +0 -68
  159. package/styles/_mixins.scss +0 -13
  160. package/styles/v2/base/_typography.scss +0 -0
  161. package/styles/v2/components/guidance-block.scss +0 -74
  162. package/styles/v2/utils/_functions.scss +0 -0
  163. /package/{styles/_typography.scss → testBuild.js} +0 -0
@@ -1,25 +1,35 @@
1
- import { Footnote } from '../../types/Footnotes'
2
- import './footnotes.css'
3
-
4
- type FootnotesProps = {
5
- footnotes: Footnote[]
6
- }
7
-
8
- const Footnotes: React.FC<FootnotesProps> = ({ footnotes }) => {
9
- return (
10
- <footer className='col-12 m-3 mt-1 mb-0'>
11
- <ul className='cove-footnotes'>
12
- {footnotes.map((note, i) => {
13
- return (
14
- <li key={`${note.symbol || 'footnote-'}${i}`} className='mb-1'>
15
- {note.symbol && <span className='me-1'>{note.symbol}</span>}
16
- {note.text}
17
- </li>
18
- )
19
- })}
20
- </ul>
21
- </footer>
22
- )
23
- }
24
-
25
- export default Footnotes
1
+ import { Footnote } from '../../types/Footnotes'
2
+ import parse from 'html-react-parser'
3
+ import './footnotes.css'
4
+
5
+ type FootnotesProps = {
6
+ footnotes: Footnote[]
7
+ }
8
+
9
+ const Footnotes: React.FC<FootnotesProps> = ({ footnotes }) => {
10
+ // Convert newlines to <br> tags and parse HTML
11
+ const processFootnoteText = (text: string) => {
12
+ if (!text) return ''
13
+ // Convert newline characters to <br> tags
14
+ const textWithBreaks = text.replace(/\n/g, '<br>')
15
+ // Parse HTML (html-react-parser handles sanitization)
16
+ return parse(textWithBreaks)
17
+ }
18
+
19
+ return (
20
+ <footer className='col-12 m-3 mt-1 mb-0'>
21
+ <ul className='cove-footnotes'>
22
+ {footnotes.map((note, i) => {
23
+ return (
24
+ <li key={`${note.symbol || 'footnote-'}${i}`} className='mb-1'>
25
+ {note.symbol && <span className='me-1'>{note.symbol}</span>}
26
+ {processFootnoteText(note.text)}
27
+ </li>
28
+ )
29
+ })}
30
+ </ul>
31
+ </footer>
32
+ )
33
+ }
34
+
35
+ export default Footnotes
@@ -1,17 +1,45 @@
1
1
  import Footnotes from './Footnotes'
2
- import FootnotesConfig from '../../types/Footnotes'
2
+ import FootnotesConfig, { Footnote } from '../../types/Footnotes'
3
3
  import _ from 'lodash'
4
4
  import { useMemo } from 'react'
5
5
  import { filterVizData } from '../../helpers/filterVizData'
6
6
  import { VizFilter } from '../../types/VizFilter'
7
+ import { MarkupVariable } from '../../types/MarkupVariable'
8
+ import { processMarkupVariables } from '../../helpers/markupProcessor'
7
9
 
8
10
  type StandAloneProps = {
9
11
  config: FootnotesConfig
10
12
  filters?: VizFilter[]
13
+ markupVariables?: MarkupVariable[]
14
+ enableMarkupVariables?: boolean
15
+ data?: Object[]
11
16
  }
12
17
 
13
- const FootnotesStandAlone: React.FC<StandAloneProps> = ({ config, filters }) => {
18
+ const FootnotesStandAlone: React.FC<StandAloneProps> = ({ config, filters, markupVariables = [], enableMarkupVariables = false, data = [] }) => {
14
19
  if (!config) return null
20
+
21
+ // Helper function to process markup variables in footnote text
22
+ const processFootnoteText = (text: string): string => {
23
+ if (!enableMarkupVariables || !markupVariables || markupVariables.length === 0) {
24
+ return text
25
+ }
26
+
27
+ // Use data from props if available, otherwise use config.data
28
+ const footnoteData = data.length > 0 ? data : config.data || []
29
+
30
+ const { processedContent } = processMarkupVariables(
31
+ text,
32
+ footnoteData,
33
+ markupVariables,
34
+ {
35
+ filters,
36
+ isEditor: false
37
+ }
38
+ )
39
+
40
+ return processedContent
41
+ }
42
+
15
43
  // get the api footnotes from the config
16
44
  const apiFootnotes = useMemo(() => {
17
45
  // If filters exist and should filter footnotes, apply them, otherwise use data as-is
@@ -20,13 +48,21 @@ const FootnotesStandAlone: React.FC<StandAloneProps> = ({ config, filters }) =>
20
48
  const { symbolColumn, textColumn, orderColumn } = config.dynamicFootnotes
21
49
  const _data = configData.map(row => _.pick(row, [symbolColumn, textColumn, orderColumn]))
22
50
  _data.sort((a, b) => a[orderColumn] - b[orderColumn])
23
- return _data.map(row => ({ symbol: row[symbolColumn], text: row[textColumn] }))
51
+ return _data.map(row => ({
52
+ symbol: row[symbolColumn],
53
+ text: processFootnoteText(row[textColumn])
54
+ }))
24
55
  }
25
56
  return []
26
- }, [config.dynamicFootnotes, config.data, filters])
57
+ }, [config.dynamicFootnotes, config.data, filters, markupVariables, enableMarkupVariables, data])
27
58
 
28
- // get static footnotes from the config.footnotes
29
- const staticFootnotes = config.staticFootnotes || []
59
+ // get static footnotes from the config.footnotes and process their text
60
+ const staticFootnotes: Footnote[] = useMemo(() => {
61
+ return (config.staticFootnotes || []).map(footnote => ({
62
+ ...footnote,
63
+ text: processFootnoteText(footnote.text)
64
+ }))
65
+ }, [config.staticFootnotes, markupVariables, enableMarkupVariables, data, filters])
30
66
 
31
67
  return <Footnotes footnotes={[...apiFootnotes, ...staticFootnotes]} />
32
68
  }
@@ -0,0 +1,43 @@
1
+ /* HeaderThemeSelector component styles */
2
+
3
+ .header {
4
+ margin-bottom: 1rem;
5
+ }
6
+
7
+ .header .edit-label {
8
+ display: block;
9
+ margin-bottom: 0.5rem;
10
+ font-weight: 500;
11
+ }
12
+
13
+ .header .color-palette {
14
+ display: flex;
15
+ flex-wrap: wrap;
16
+ gap: 0.5rem;
17
+ list-style: none;
18
+ margin: 0;
19
+ padding: 0;
20
+ }
21
+
22
+ .header .color-palette button {
23
+ width: 30px;
24
+ height: 30px;
25
+ border-radius: 50%;
26
+ border: 2px solid transparent;
27
+ cursor: pointer;
28
+ transition: all 0.2s ease;
29
+ outline: none;
30
+ }
31
+
32
+ .header .color-palette button:hover {
33
+ transform: scale(1.1);
34
+ }
35
+
36
+ .header .color-palette button.selected {
37
+ border-color: #000;
38
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
39
+ }
40
+
41
+ .header .color-palette button:focus {
42
+ box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
43
+ }
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import HeaderThemeSelector from '../HeaderThemeSelector'
3
+
4
+ const meta: Meta<typeof HeaderThemeSelector> = {
5
+ title: 'Components/Atoms/HeaderThemeSelector',
6
+ component: HeaderThemeSelector,
7
+ parameters: {
8
+ docs: {
9
+ description: {
10
+ component: 'A reusable component for selecting header themes across different visualization types.'
11
+ }
12
+ }
13
+ },
14
+ argTypes: {
15
+ onThemeSelect: { action: 'theme-selected' },
16
+ selectedTheme: {
17
+ control: 'select',
18
+ options: [
19
+ 'theme-blue',
20
+ 'theme-purple',
21
+ 'theme-brown',
22
+ 'theme-teal',
23
+ 'theme-pink',
24
+ 'theme-orange',
25
+ 'theme-slate',
26
+ 'theme-indigo',
27
+ 'theme-cyan',
28
+ 'theme-green',
29
+ 'theme-amber'
30
+ ]
31
+ }
32
+ }
33
+ } satisfies Meta<typeof HeaderThemeSelector>
34
+
35
+ export default meta
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ const defaultHeaderColors = [
39
+ 'theme-blue',
40
+ 'theme-purple',
41
+ 'theme-brown',
42
+ 'theme-teal',
43
+ 'theme-pink',
44
+ 'theme-orange',
45
+ 'theme-slate',
46
+ 'theme-indigo',
47
+ 'theme-cyan',
48
+ 'theme-green',
49
+ 'theme-amber'
50
+ ]
51
+
52
+ export const Default: Story = {
53
+ args: {}
54
+ }
55
+
56
+ export const WithSelectedTheme: Story = {
57
+ args: {
58
+ selectedTheme: 'theme-purple'
59
+ }
60
+ }
61
+
62
+ export const CustomLabel: Story = {
63
+ args: {
64
+ label: 'Choose Color Theme',
65
+ selectedTheme: 'theme-teal'
66
+ }
67
+ }
68
+
69
+ export const CustomColors: Story = {
70
+ args: {
71
+ headerColors: ['theme-blue', 'theme-purple', 'theme-orange', 'theme-green'],
72
+ selectedTheme: 'theme-blue'
73
+ }
74
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react'
2
+ import './HeaderThemeSelector.css'
3
+
4
+ // Default header theme colors used across all CDC Open Viz packages
5
+ const DEFAULT_HEADER_COLORS = [
6
+ 'theme-blue',
7
+ 'theme-purple',
8
+ 'theme-brown',
9
+ 'theme-teal',
10
+ 'theme-pink',
11
+ 'theme-orange',
12
+ 'theme-slate',
13
+ 'theme-indigo',
14
+ 'theme-cyan',
15
+ 'theme-green',
16
+ 'theme-amber'
17
+ ]
18
+
19
+ interface HeaderThemeSelectorProps {
20
+ /** Array of theme color names to display. Defaults to standard CDC theme colors */
21
+ headerColors?: string[]
22
+ /** Currently selected theme */
23
+ selectedTheme?: string
24
+ /** Callback when a theme is selected */
25
+ onThemeSelect: (theme: string) => void
26
+ /** Optional label for the selector */
27
+ label?: string
28
+ /** Optional CSS class name */
29
+ className?: string
30
+ }
31
+
32
+ const HeaderThemeSelector: React.FC<HeaderThemeSelectorProps> = ({
33
+ headerColors = DEFAULT_HEADER_COLORS,
34
+ selectedTheme,
35
+ onThemeSelect,
36
+ label = 'Header Theme',
37
+ className = 'color-palette'
38
+ }) => {
39
+ const handleThemeSelection = (theme: string) => (e: React.MouseEvent) => {
40
+ e.preventDefault()
41
+ onThemeSelect(theme)
42
+ }
43
+
44
+ return (
45
+ <label className='header'>
46
+ <span className='edit-label'>{label}</span>
47
+ <ul className={className}>
48
+ {headerColors.map(theme => (
49
+ <button
50
+ title={theme}
51
+ key={theme}
52
+ onClick={handleThemeSelection(theme)}
53
+ className={selectedTheme === theme ? `selected ${theme}` : theme}
54
+ />
55
+ ))}
56
+ </ul>
57
+ </label>
58
+ )
59
+ }
60
+
61
+ export default HeaderThemeSelector
@@ -0,0 +1,2 @@
1
+ export { default as HeaderThemeSelector } from './HeaderThemeSelector'
2
+ export { default } from './HeaderThemeSelector'
@@ -1,5 +1,6 @@
1
+ @import '../../../styles/v2/utils/variables';
2
+
1
3
  $editorAnimationTimer: 400ms;
2
- $editorWidth: 350px;
3
4
  $mediumGray: #e6e6e6;
4
5
 
5
6
  @import 'editor-grid-view.scss';
@@ -70,7 +70,7 @@ const LegendGradient = ({
70
70
  const lastTick = index === labels.length - 1
71
71
 
72
72
  return (
73
- <Group top={MARGIN}>
73
+ <Group key={`tick-${index}`} top={MARGIN}>
74
74
  {!lastTick && !isLinearBlocks && <line x1={xPositionX} x2={xPositionX} y1={30} y2={boxHeight} stroke='black' />}
75
75
  <Text
76
76
  angle={-tickRotation}
@@ -123,9 +123,8 @@ const LegendGradient = ({
123
123
  const segmentWidth = (legendWidth - legendSeparatorsToSubtract) / numTicks
124
124
  const xPosition = index * segmentWidth + MARGIN + getTickSeparatorsAdjustment(index)
125
125
  return (
126
- <Group>
126
+ <Group key={`color-block-${index}`}>
127
127
  <rect
128
- key={index}
129
128
  x={xPosition}
130
129
  y={MARGIN}
131
130
  width={segmentWidth}
@@ -142,10 +141,9 @@ const LegendGradient = ({
142
141
  const segmentWidth = (legendWidth - legendSeparatorsToSubtract) / numTicks
143
142
  const xPosition = separatorAfter * segmentWidth + MARGIN + getTickSeparatorsAdjustment(separatorAfter - 1)
144
143
  return (
145
- <Group>
144
+ <Group key={`separator-${index}`}>
146
145
  {/* Separators block */}
147
146
  <rect
148
- key={index}
149
147
  x={xPosition}
150
148
  y={MARGIN / 2}
151
149
  width={separatorSize}
@@ -157,7 +155,6 @@ const LegendGradient = ({
157
155
 
158
156
  {/* Dotted dividing line */}
159
157
  <line
160
- key={index}
161
158
  x1={xPosition + separatorSize / 2}
162
159
  x2={xPosition + separatorSize / 2}
163
160
  y1={-3}
@@ -1,24 +1,142 @@
1
1
  import React from 'react'
2
2
 
3
+ interface PatternInfo {
4
+ pattern: string
5
+ patternId: string
6
+ size?: string
7
+ color?: string
8
+ }
9
+
3
10
  interface LegendShapeProps {
4
11
  fill: string
5
12
  borderColor?: string
6
13
  display?: 'inline-block' | 'block' | 'inline'
7
14
  shape?: 'circle' | 'square'
15
+ patternInfo?: PatternInfo
8
16
  }
9
17
 
10
18
  const LegendShape: React.FC<LegendShapeProps> = props => {
11
- const { fill, borderColor, display = 'inline-block', shape = 'circle' } = props
19
+ const { fill, borderColor, display = 'inline-block', shape = 'circle', patternInfo } = props
12
20
  const dimensions = { width: '1em', height: '1em' }
13
21
  const isCircleOrSquare = ['circle', 'square'].includes(shape)
22
+
23
+ // If pattern is provided, use SVG with pattern fill
24
+ if (patternInfo) {
25
+ const sizes = {
26
+ small: '8',
27
+ medium: '10',
28
+ large: '12'
29
+ }
30
+
31
+ const patternSize = sizes[patternInfo.size as keyof typeof sizes] || '10'
32
+ // Use the exact pattern color from config, with a reliable fallback
33
+ const patternColor = patternInfo.color || '#212529'
34
+
35
+ return (
36
+ <span className={`legend-item ${isCircleOrSquare ? 'me-2' : ''}`} style={{ display, verticalAlign: 'middle', width: dimensions.width, height: dimensions.height }}>
37
+ <svg width="100%" height="100%" viewBox="0 0 16 16" className="legend-shape-svg">
38
+ {/* Pattern definitions */}
39
+ <defs>
40
+ {patternInfo.pattern === 'waves' && (
41
+ <pattern
42
+ id={patternInfo.patternId}
43
+ patternUnits="userSpaceOnUse"
44
+ width={patternSize}
45
+ height={patternSize}
46
+ >
47
+ <path
48
+ d={`M0,${parseInt(patternSize) / 2} Q${parseInt(patternSize) / 4},0 ${parseInt(patternSize) / 2},${parseInt(patternSize) / 2} T${patternSize},${parseInt(patternSize) / 2}`}
49
+ stroke={patternColor}
50
+ strokeWidth="0.25"
51
+ fill="none"
52
+ />
53
+ </pattern>
54
+ )}
55
+ {patternInfo.pattern === 'circles' && (
56
+ <pattern
57
+ id={patternInfo.patternId}
58
+ patternUnits="userSpaceOnUse"
59
+ width={patternSize}
60
+ height={patternSize}
61
+ >
62
+ <circle
63
+ cx={parseInt(patternSize) / 2}
64
+ cy={parseInt(patternSize) / 2}
65
+ r="1.25"
66
+ fill={patternColor}
67
+ />
68
+ </pattern>
69
+ )}
70
+ {patternInfo.pattern === 'lines' && (
71
+ <pattern
72
+ id={patternInfo.patternId}
73
+ patternUnits="userSpaceOnUse"
74
+ width={patternSize}
75
+ height={patternSize}
76
+ >
77
+ <line
78
+ x1="0"
79
+ y1="0"
80
+ x2={patternSize}
81
+ y2={patternSize}
82
+ stroke={patternColor}
83
+ strokeWidth="0.75"
84
+ />
85
+ </pattern>
86
+ )}
87
+ </defs>
88
+
89
+ {shape === 'circle' ? (
90
+ <circle
91
+ fill={fill}
92
+ r={7.5}
93
+ cx={8}
94
+ cy={8}
95
+ stroke={borderColor || 'rgba(0,0,0,.3)'}
96
+ strokeWidth={1}
97
+ />
98
+ ) : (
99
+ <rect
100
+ fill={fill}
101
+ width={15}
102
+ height={15}
103
+ x={0.5}
104
+ y={0.5}
105
+ stroke={borderColor || 'rgba(0,0,0,.3)'}
106
+ strokeWidth={1}
107
+ />
108
+ )}
109
+ {shape === 'circle' ? (
110
+ <circle
111
+ fill={`url(#${patternInfo.patternId})`}
112
+ r={7.5}
113
+ cx={8}
114
+ cy={8}
115
+ stroke='none'
116
+ />
117
+ ) : (
118
+ <rect
119
+ fill={`url(#${patternInfo.patternId})`}
120
+ width={15}
121
+ height={15}
122
+ x={0.5}
123
+ y={0.5}
124
+ stroke='none'
125
+ />
126
+ )}
127
+ </svg>
128
+ </span>
129
+ )
130
+ }
131
+
132
+ // Default solid color shape
14
133
  const styles = {
15
134
  borderRadius: shape === 'circle' ? '50%' : '0px',
16
- verticalAlign: 'middle',
17
135
  display: display,
18
136
  height: dimensions.height,
19
137
  width: dimensions.width,
20
138
  border: borderColor ? `${borderColor} 1px solid` : 'rgba(0,0,0,.3) 1px solid',
21
- backgroundColor: fill
139
+ backgroundColor: fill,
22
140
  }
23
141
 
24
142
  return <span className={`legend-item ${isCircleOrSquare ? 'me-2' : ''}`} style={styles} />
@@ -12,7 +12,7 @@ type LoaderProps = {
12
12
 
13
13
  const Spinner = ({ spinnerType }: { spinnerType: SpinnerType }) => (
14
14
  <div className={`spinner-border ${spinnerType}`} role='status'>
15
- <span className='sr-only'>Loading...</span>
15
+ <span className='sr-only' style={{ display: 'none' }}>Loading...</span>
16
16
  </div>
17
17
  )
18
18
 
@@ -1,6 +1,7 @@
1
1
  import React from 'react'
2
2
  // import html2pdf from 'html2pdf.js'
3
3
  import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
4
+ import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
4
5
 
5
6
  const buttonText = {
6
7
  pdf: 'Download PDF',
@@ -38,63 +39,99 @@ const generateMedia = (state, type, elementToCapture, interactionLabel) => {
38
39
  // Identify Selector
39
40
  const baseSvg = document.querySelector(`[data-download-id=${elementToCapture}]`)
40
41
 
42
+ // Extract title from different state structures
43
+ const getTitle = state => {
44
+ if (state?.dashboard?.title) return state.dashboard.title
45
+ if (state?.general?.title) return state.general.title
46
+ if (state?.title) return state.title
47
+ return undefined
48
+ }
49
+
50
+ // Generate timestamp once for consistency
51
+ const date = new Date()
52
+ const day = date.getDate()
53
+ const month = date.getMonth() + 1
54
+ const year = date.getFullYear()
55
+ const timestamp = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
56
+
41
57
  // Handles different state title locations between components
42
58
  // Apparently some packages use state.title where others use state.general.title
43
59
  const handleFileName = state => {
44
60
  // dashboard titles
45
61
  if (state?.dashboard?.title)
46
62
  return (
47
- state.dashboard.title.replace(/\s+/g, '-').toLowerCase() +
48
- '-' +
49
- date.getDate() +
50
- date.getMonth() +
51
- date.getFullYear()
63
+ `${state.dashboard.title.replace(/\s+/g, '-').toLowerCase()}-${timestamp}`
52
64
  )
53
65
 
54
66
  // map titles
55
67
  if (state?.general?.title)
56
68
  return (
57
- state.general.title.replace(/\s+/g, '-').toLowerCase() +
58
- '-' +
59
- date.getDate() +
60
- date.getMonth() +
61
- date.getFullYear()
69
+ `${state.general.title.replace(/\s+/g, '-').toLowerCase()}-${timestamp}`
62
70
  )
63
71
 
64
72
  // chart titles
65
73
  if (state?.title)
66
74
  return (
67
- state.title.replace(/\s+/g, '-').toLowerCase() + '-' + date.getDate() + date.getMonth() + date.getFullYear()
75
+ `${state.title.replace(/\s+/g, '-').toLowerCase()}-${timestamp}`
68
76
  )
69
77
 
70
78
  return 'no-title'
71
79
  }
72
80
 
73
- // Construct filename with timestamp
74
- const date = new Date()
75
81
  const filename = handleFileName(state)
76
82
 
77
83
  switch (type) {
78
84
  case 'image':
79
85
  const container = document.createElement('div')
80
- // On screenshots without a title (like some charts), add padding around the chart svg
81
- if (!state.showTitle) {
82
- container.style.padding = '35px'
86
+
87
+ // Simple configurable padding (main fix for spacing issues)
88
+ const downloadPadding = state.downloadImagePadding !== undefined ? state.downloadImagePadding : (!state.showTitle ? 35 : 0)
89
+ if (downloadPadding > 0) {
90
+ container.style.padding = `${downloadPadding}px`
83
91
  }
84
- container.appendChild(baseSvg.cloneNode(true)) // Clone baseSvg to avoid modifying the original
92
+
93
+ container.appendChild(baseSvg.cloneNode(true));
85
94
 
86
95
  const downloadImage = async () => {
87
96
  document.body.appendChild(container) // Append container to the DOM
97
+
98
+ // Fix select elements to show their current selected values before screenshot
99
+ const selectElements = container.querySelectorAll('select')
100
+ const originalSelects = baseSvg.querySelectorAll('select')
101
+
102
+ selectElements.forEach((select, index) => {
103
+ const originalSelect = originalSelects[index]
104
+ if (originalSelect && originalSelect.value) {
105
+ select.value = originalSelect.value
106
+ // Also update the visual display for browsers that don't update immediately
107
+ const selectedOption = select.querySelector(`option[value="${originalSelect.value}"]`) as HTMLOptionElement
108
+ if (selectedOption) {
109
+ selectedOption.selected = true
110
+ }
111
+ }
112
+ })
113
+
88
114
  import(/* webpackChunkName: "html2canvas" */ 'html2canvas').then(mod => {
89
115
  mod
90
116
  .default(container, {
91
117
  ignoreElements: el =>
92
118
  el.className?.indexOf &&
93
- el.className.search(/download-buttons|download-links|data-table-container/) !== -1
119
+ el.className.search(/download-buttons|download-links|data-table-container/) !== -1,
120
+ useCORS: true,
121
+ scale: 2, // Better quality
122
+ allowTaint: true,
94
123
  })
95
124
  .then(canvas => {
125
+ document.body.removeChild(container) // Clean up container
96
126
  saveImageAs(canvas.toDataURL(), filename + '.png')
97
- publishAnalyticsEvent(`${state.type}_image_downloaded`, 'click', interactionLabel, `${state.type}`)
127
+ publishAnalyticsEvent({
128
+ vizType: state.type,
129
+ vizSubType: getVizSubType(state),
130
+ eventType: `image_download`,
131
+ eventAction: 'click',
132
+ eventLabel: interactionLabel,
133
+ vizTitle: getTitle(state)
134
+ })
98
135
  })
99
136
  })
100
137
  }
@@ -151,7 +188,14 @@ const Link = ({ config, dashboardDataConfig, interactionLabel }) => {
151
188
  title={buttonText.link}
152
189
  target='_blank'
153
190
  onClick={() => {
154
- publishAnalyticsEvent('data_viewed', 'click', `${unknown}`)
191
+ publishAnalyticsEvent({
192
+ vizType: config.type,
193
+ vizSubType: getVizSubType(config),
194
+ eventType: 'clicked_data_link_to_view',
195
+ eventAction: 'click',
196
+ eventLabel: interactionLabel,
197
+ vizTitle: getVizTitle(config)
198
+ })
155
199
  }}
156
200
  >
157
201
  {buttonText.link}
@@ -166,7 +210,14 @@ const Link = ({ config, dashboardDataConfig, interactionLabel }) => {
166
210
  title='Link to view full data set'
167
211
  target='_blank'
168
212
  onClick={() => {
169
- publishAnalyticsEvent('data_viewed', 'click', `${interactionLabel}`)
213
+ publishAnalyticsEvent({
214
+ vizType: config.type,
215
+ vizSubType: getVizSubType(config),
216
+ eventType: 'data_viewed',
217
+ eventAction: 'click',
218
+ eventLabel: interactionLabel,
219
+ vizTitle: getVizTitle(config)
220
+ })
170
221
  }}
171
222
  >
172
223
  {buttonText.link}