@cdc/core 4.25.11 → 4.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/qa-test-developer.md +126 -0
- package/CLAUDE.local.md +67 -0
- package/_stories/Gallery.Charts.stories.tsx +300 -0
- package/_stories/Gallery.DataBite.stories.tsx +79 -0
- package/_stories/Gallery.Maps.stories.tsx +239 -0
- package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
- package/_stories/PageART.stories.tsx +193 -0
- package/_stories/PageBRFSS.stories.tsx +294 -0
- package/_stories/PageCancerRegistries.stories.tsx +199 -0
- package/_stories/PageEasternEquineEncephalitis.stories.tsx +216 -0
- package/_stories/PageExcessiveAlcoholUse.stories.tsx +201 -0
- package/_stories/PageMaternalMortality.stories.tsx +193 -0
- package/_stories/PageOralHealth.stories.tsx +201 -0
- package/_stories/PageRespiratory.stories.tsx +332 -0
- package/_stories/PageSmokingTobacco.stories.tsx +200 -0
- package/_stories/PageStateDiabetesProfiles.stories.tsx +201 -0
- package/_stories/PageWastewater.stories.tsx +477 -0
- package/_stories/VegaImport.stories.tsx +401 -0
- package/_stories/vega-fixtures/bars-with-line.json +444 -0
- package/_stories/vega-fixtures/bars.json +58 -0
- package/_stories/vega-fixtures/combo-bar-rolling-mean.json +88 -0
- package/_stories/vega-fixtures/combo.json +68 -0
- package/_stories/vega-fixtures/grouped-horizontal-bars.json +83 -0
- package/_stories/vega-fixtures/grouped-horizontal-bars2.json +231 -0
- package/_stories/vega-fixtures/horizontal-bar.json +427 -0
- package/_stories/vega-fixtures/horizontal-bars-with-bad-colors.json +197 -0
- package/_stories/vega-fixtures/horizontal-bars2.json +58 -0
- package/_stories/vega-fixtures/lines.json +227 -0
- package/_stories/vega-fixtures/measles-bars.json +348 -0
- package/_stories/vega-fixtures/measles-map.json +11101 -0
- package/_stories/vega-fixtures/measles-stacked-bars.json +2147 -0
- package/_stories/vega-fixtures/multi-dataset.json +255 -0
- package/_stories/vega-fixtures/no-data.json +14 -0
- package/_stories/vega-fixtures/pie-chart.json +94 -0
- package/_stories/vega-fixtures/repeat-spec.json +47 -0
- package/_stories/vega-fixtures/stacked-area.json +222 -0
- package/_stories/vega-fixtures/stacked-bar-with-rect.json +3412 -0
- package/_stories/vega-fixtures/stacked-bars-with-line.json +364 -0
- package/_stories/vega-fixtures/stacked-bars.json +212 -0
- package/_stories/vega-fixtures/stacked-horizontal-bars.json +140 -0
- package/_stories/vega-fixtures/warning-combo.json +59 -0
- package/_stories/vega-fixtures/warning-scatter-and-line.json +1182 -0
- package/assets/icon-chart-area.svg +1 -0
- package/assets/icon-chart-radar.svg +23 -0
- package/assets/icon-magnifying-glass.svg +5 -0
- package/assets/icon-warming-stripes.svg +13 -0
- package/assets/logo2.svg +31 -0
- package/components/AdvancedEditor/AdvancedEditor.tsx +4 -0
- package/components/AdvancedEditor/EmbedEditor.tsx +513 -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.tsx +3 -10
- package/components/DataTable/DataTable.tsx +132 -58
- package/components/DataTable/data-table.css +216 -215
- package/components/DataTable/helpers/getSeriesName.ts +6 -0
- package/components/DataTable/helpers/mapCellMatrix.tsx +14 -6
- package/components/EditorPanel/ColumnsEditor.tsx +37 -19
- package/components/EditorPanel/DataTableEditor.tsx +51 -25
- package/components/EditorPanel/EditorPanel.styles.css +16 -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/Inputs.tsx +33 -7
- package/components/EditorPanel/VizFilterEditor/NestedDropdownEditor.tsx +14 -6
- package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +240 -175
- package/components/EditorPanel/VizFilterEditor/components/FilterOrder.tsx +33 -29
- package/components/EditorPanel/sections/VisualSection.tsx +169 -0
- package/components/Filters/Filters.tsx +31 -5
- package/components/Filters/helpers/getNestedOptions.ts +2 -1
- package/components/Filters/helpers/handleSorting.ts +1 -1
- package/components/Layout/components/Sidebar/components/sidebar.styles.scss +84 -2
- package/components/Layout/components/Visualization/index.tsx +27 -1
- package/components/Layout/components/Visualization/visualizations.scss +7 -0
- package/components/Legend/Legend.Gradient.tsx +1 -1
- package/components/MediaControls.tsx +53 -28
- package/components/_stories/CustomColorsEditor.stories.tsx +37 -0
- package/components/_stories/DataTable.stories.tsx +1 -0
- 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/data/colorPalettes.ts +18 -5
- package/data/mapColorPalettes.ts +10 -0
- package/devTemplate/dev.js +235 -0
- package/devTemplate/index.html +30 -0
- package/devTemplate/preview.html +1503 -0
- package/devTemplate/sidebar.css +151 -0
- package/dist/cove-main.css +2803 -4448
- package/dist/cove-main.css.map +1 -1
- package/generateViteConfig.js +118 -2
- package/helpers/DataTransform.ts +1 -5
- package/helpers/addValuesToFilters.ts +6 -1
- package/helpers/cove/date.ts +33 -1
- package/helpers/cove/string.ts +29 -0
- package/helpers/coveUpdateWorker.ts +21 -12
- package/helpers/embed/embedCodeGenerator.ts +80 -0
- package/helpers/embed/embedHelper.js +158 -0
- package/helpers/embed/filterUtils.ts +121 -0
- package/helpers/embed/index.ts +21 -0
- package/helpers/embed/urlValidation.ts +119 -0
- package/helpers/filterVizData.ts +6 -1
- package/helpers/getFileExtension.ts +0 -6
- package/helpers/getUniqueValues.ts +19 -0
- package/helpers/hashObj.ts +25 -0
- package/helpers/isRightAlignedTableValue.js +5 -0
- package/helpers/metrics/helpers.ts +1 -0
- package/helpers/metrics/types.ts +3 -0
- package/helpers/palettes/colorDistributions.ts +1 -1
- package/helpers/palettes/utils.ts +12 -12
- package/helpers/parseCsvWithQuotes.ts +15 -14
- package/helpers/pivotData.ts +2 -2
- package/helpers/prepareScreenshot.ts +288 -0
- package/helpers/queryStringUtils.ts +29 -0
- package/helpers/testing.ts +44 -0
- package/helpers/tests/DataTransform.test.ts +125 -0
- package/helpers/tests/date.test.ts +64 -0
- 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/vegaConfig.ts +1 -1
- package/helpers/vegaConfigImport.ts +160 -0
- package/helpers/ver/4.26.1.ts +80 -0
- package/helpers/ver/4.26.2.ts +84 -0
- package/helpers/ver/tests/4.26.1.test.ts +105 -0
- package/helpers/ver/tests/4.26.2.test.ts +298 -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 +29 -33
- package/styles/_button-section.scss +0 -3
- package/styles/v2/components/editor.scss +9 -9
- package/styles/v2/utils/_grid.scss +8 -3
- package/types/Annotation.ts +10 -11
- package/types/Axis.ts +1 -0
- package/types/ForecastingSeriesKey.ts +1 -0
- package/types/General.ts +2 -0
- package/types/MarkupInclude.ts +1 -0
- package/types/Palette.ts +21 -0
- package/types/Series.ts +3 -0
- package/types/Table.ts +1 -0
- package/types/Visualization.ts +7 -0
- package/types/VizFilter.ts +1 -0
- package/LICENSE +0 -201
- package/_stories/StoryRenderingTests.stories.tsx +0 -164
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
* Remove width classes that interfere with screenshot sizing
|
|
213
|
+
*/
|
|
214
|
+
function removeWidthClasses(clonedTree: HTMLElement): void {
|
|
215
|
+
const classesToRemove = ['dfe-block--width-wide', 'dfe-block--width-full_width']
|
|
216
|
+
|
|
217
|
+
classesToRemove.forEach(className => {
|
|
218
|
+
const elements = clonedTree.querySelectorAll(`.${className}`)
|
|
219
|
+
elements.forEach(el => el.classList.remove(className))
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Also check the root element itself
|
|
223
|
+
classesToRemove.forEach(className => {
|
|
224
|
+
clonedTree.classList.remove(className)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Expand SVG widths and remove animation classes
|
|
230
|
+
*/
|
|
231
|
+
function expandSvgWidths(clonedViz: HTMLElement): void {
|
|
232
|
+
const svgWidthBuffer = 25
|
|
233
|
+
const svgElements = clonedViz.querySelectorAll('svg')
|
|
234
|
+
|
|
235
|
+
svgElements.forEach(svg => {
|
|
236
|
+
const currentWidth = parseInt(svg.getAttribute('width') || '0')
|
|
237
|
+
if (currentWidth > 0) {
|
|
238
|
+
svg.setAttribute('width', (currentWidth + svgWidthBuffer).toString())
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Remove animation classes to show final state immediately
|
|
242
|
+
svg.classList.remove('animated', 'animate')
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Main function: Prepare a complete container ready for html2canvas screenshot
|
|
248
|
+
*/
|
|
249
|
+
export function prepareScreenshotContainer(
|
|
250
|
+
baseSvg: HTMLElement,
|
|
251
|
+
includeContextInDownload: boolean,
|
|
252
|
+
elementToCapture: string
|
|
253
|
+
): HTMLDivElement {
|
|
254
|
+
// 1. Clone elements (with or without context)
|
|
255
|
+
const { clonedTree, clonedViz } = prepareClonedElements(baseSvg, includeContextInDownload, elementToCapture)
|
|
256
|
+
|
|
257
|
+
// 2. Remove width classes that interfere with screenshot sizing
|
|
258
|
+
removeWidthClasses(clonedTree)
|
|
259
|
+
|
|
260
|
+
// 3. Strip all links (not clickable in static image)
|
|
261
|
+
stripLinks(clonedTree)
|
|
262
|
+
|
|
263
|
+
// 4. Convert canvas elements to images
|
|
264
|
+
convertCanvasToImages(baseSvg, clonedViz)
|
|
265
|
+
|
|
266
|
+
// 5. Expand SVG widths to prevent clipping
|
|
267
|
+
expandSvgWidths(clonedViz)
|
|
268
|
+
|
|
269
|
+
// 6. Calculate viz dimensions
|
|
270
|
+
const computedStyle = getComputedStyle(baseSvg)
|
|
271
|
+
const vizWidth =
|
|
272
|
+
parseFloat(computedStyle.width) -
|
|
273
|
+
(parseFloat(computedStyle.paddingLeft) || 0) -
|
|
274
|
+
(parseFloat(computedStyle.paddingRight) || 0)
|
|
275
|
+
|
|
276
|
+
// 7. Create and style container
|
|
277
|
+
const container = document.createElement('div')
|
|
278
|
+
container.style.width = `${vizWidth + 36}px`
|
|
279
|
+
container.style.padding = '18px'
|
|
280
|
+
|
|
281
|
+
// 8. Reset viz padding
|
|
282
|
+
clonedViz.style.padding = '0'
|
|
283
|
+
|
|
284
|
+
// 9. Append cloned tree to container
|
|
285
|
+
container.appendChild(clonedTree)
|
|
286
|
+
|
|
287
|
+
return container
|
|
288
|
+
}
|
|
@@ -13,6 +13,35 @@ export function getQueryStringFilterValue(filter) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Check if a filter should be hidden based on URL query parameters
|
|
18
|
+
* Checks for hide{setByQueryParameter}=true|1|yes in the URL
|
|
19
|
+
*
|
|
20
|
+
* @param filter - Filter object with setByQueryParameter property
|
|
21
|
+
* @returns true if filter should be hidden, false otherwise
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // If filter has setByQueryParameter: "state"
|
|
25
|
+
* // URL: ?hidestate=true -> returns true
|
|
26
|
+
* // URL: ?hideState=true -> returns false (case sensitive!)
|
|
27
|
+
*/
|
|
28
|
+
export function isFilterHiddenByQuery(filter) {
|
|
29
|
+
if (!filter) return false
|
|
30
|
+
|
|
31
|
+
// Only check setByQueryParameter - this is the standard way filters are identified in URL params
|
|
32
|
+
const paramName = filter.setByQueryParameter
|
|
33
|
+
if (!paramName) return false
|
|
34
|
+
|
|
35
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
36
|
+
const hideParamName = `hide${paramName}`
|
|
37
|
+
const hideValue = urlParams.get(hideParamName)
|
|
38
|
+
|
|
39
|
+
if (!hideValue) return false
|
|
40
|
+
|
|
41
|
+
const lower = hideValue.toLowerCase()
|
|
42
|
+
return lower === 'true' || hideValue === '1' || lower === 'yes'
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
export function getQueryParams() {
|
|
17
46
|
const queryParams = {}
|
|
18
47
|
for (const [key, value] of Array.from(new URLSearchParams(window.location.search).entries())) {
|
package/helpers/testing.ts
CHANGED
|
@@ -313,6 +313,50 @@ export const testBooleanControl = async (checkbox: HTMLInputElement, getVisualSt
|
|
|
313
313
|
expect(secondToggleVisualState).toEqual(initialVisualState)
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
// ============================================================================
|
|
317
|
+
// VISUALIZATION RENDERING ASSERTIONS
|
|
318
|
+
// ============================================================================
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Assert that a visualization has rendered successfully.
|
|
322
|
+
* Uses performAndAssert to poll until one of these conditions is met:
|
|
323
|
+
* ((svgCount > 0 || canvasCount > 0) && hasCoveModule) || isDataBite || isDataTable
|
|
324
|
+
*
|
|
325
|
+
* Handles all visualization types:
|
|
326
|
+
* - Charts (SVG-based)
|
|
327
|
+
* - Maps with SVG rendering (US state, world, hex, etc.)
|
|
328
|
+
* - Maps with canvas rendering (county maps via UsaMap.County)
|
|
329
|
+
* - Data bites (.bite-content)
|
|
330
|
+
* - Data tables (.data-table)
|
|
331
|
+
* - Waffle charts (SVG-based)
|
|
332
|
+
*
|
|
333
|
+
* Use as a play function in any Components/Templates/ story:
|
|
334
|
+
* play: async ({ canvasElement }) => {
|
|
335
|
+
* await assertVisualizationRendered(canvasElement)
|
|
336
|
+
* }
|
|
337
|
+
*
|
|
338
|
+
* @param vizElement The story's canvas element (Storybook's canvasElement)
|
|
339
|
+
*/
|
|
340
|
+
export const assertVisualizationRendered = async (vizElement: HTMLElement) => {
|
|
341
|
+
await performAndAssert(
|
|
342
|
+
'Wait for visualization to render',
|
|
343
|
+
() => {
|
|
344
|
+
const svgCount = vizElement.querySelectorAll('svg').length
|
|
345
|
+
const canvasCount = vizElement.querySelectorAll('canvas').length
|
|
346
|
+
const hasCoveModule = !!vizElement.querySelector('.cdc-open-viz-module')
|
|
347
|
+
const isDataBite = !!vizElement.querySelector('.bite-content')
|
|
348
|
+
const isDataTable = !!vizElement.querySelector('.type-data-table')
|
|
349
|
+
return { svgCount, canvasCount, hasCoveModule, isDataBite, isDataTable }
|
|
350
|
+
},
|
|
351
|
+
async () => {},
|
|
352
|
+
(_before, after) => {
|
|
353
|
+
return (
|
|
354
|
+
((after.svgCount > 0 || after.canvasCount > 0) && after.hasCoveModule) || after.isDataBite || after.isDataTable
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
316
360
|
// ============================================================================
|
|
317
361
|
// DATA EXTRACTION HELPERS
|
|
318
362
|
// ============================================================================
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import DataTransform from '../DataTransform'
|
|
3
|
+
|
|
4
|
+
describe('DataTransform', () => {
|
|
5
|
+
let transformer: DataTransform
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
transformer = new DataTransform()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('autoStandardize', () => {
|
|
12
|
+
it('returns undefined for empty data', () => {
|
|
13
|
+
expect(transformer.autoStandardize([])).toBeUndefined()
|
|
14
|
+
expect(transformer.autoStandardize(null)).toBeUndefined()
|
|
15
|
+
expect(transformer.autoStandardize(undefined)).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns undefined for invalid data format', () => {
|
|
19
|
+
expect(transformer.autoStandardize(['string', 'array'])).toBeUndefined()
|
|
20
|
+
expect(transformer.autoStandardize([1, 2, 3])).toBeUndefined()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns data unchanged when already in object format', () => {
|
|
24
|
+
const data = [
|
|
25
|
+
{ name: 'Alice', value: 10 },
|
|
26
|
+
{ name: 'Bob', value: 20 }
|
|
27
|
+
]
|
|
28
|
+
const result = transformer.autoStandardize(data)
|
|
29
|
+
expect(result).toEqual(data)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('converts array of arrays to array of objects', () => {
|
|
33
|
+
const data = [
|
|
34
|
+
['name', 'value'],
|
|
35
|
+
['Alice', 10],
|
|
36
|
+
['Bob', 20]
|
|
37
|
+
]
|
|
38
|
+
const result = transformer.autoStandardize(data)
|
|
39
|
+
expect(result).toEqual([
|
|
40
|
+
{ name: 'Alice', value: 10 },
|
|
41
|
+
{ name: 'Bob', value: 20 }
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('handles array of arrays with multiple columns', () => {
|
|
46
|
+
const data = [
|
|
47
|
+
['state', 'year', 'population'],
|
|
48
|
+
['CA', 2020, 39538223],
|
|
49
|
+
['TX', 2020, 29145505]
|
|
50
|
+
]
|
|
51
|
+
const result = transformer.autoStandardize(data)
|
|
52
|
+
expect(result).toEqual([
|
|
53
|
+
{ state: 'CA', year: 2020, population: 39538223 },
|
|
54
|
+
{ state: 'TX', year: 2020, population: 29145505 }
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('cleanDataPoint', () => {
|
|
60
|
+
it('removes commas from numbers', () => {
|
|
61
|
+
expect(transformer.cleanDataPoint('1,234')).toBe('1234')
|
|
62
|
+
expect(transformer.cleanDataPoint('1,234,567')).toBe('1234567')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('removes dollar signs', () => {
|
|
66
|
+
expect(transformer.cleanDataPoint('$100')).toBe('100')
|
|
67
|
+
expect(transformer.cleanDataPoint('$1,234')).toBe('1234')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('removes percent signs', () => {
|
|
71
|
+
expect(transformer.cleanDataPoint('50%')).toBe('50')
|
|
72
|
+
expect(transformer.cleanDataPoint('99.9%')).toBe('99.9')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('handles combined formatting', () => {
|
|
76
|
+
expect(transformer.cleanDataPoint('$1,234,567')).toBe('1234567')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles null and empty values', () => {
|
|
80
|
+
expect(transformer.cleanDataPoint(null)).toBe('')
|
|
81
|
+
expect(transformer.cleanDataPoint('')).toBe('')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('handles non-string values', () => {
|
|
85
|
+
expect(transformer.cleanDataPoint(123)).toBe(123)
|
|
86
|
+
expect(transformer.cleanDataPoint(0)).toBe(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns plain numbers unchanged', () => {
|
|
90
|
+
expect(transformer.cleanDataPoint('123')).toBe('123')
|
|
91
|
+
expect(transformer.cleanDataPoint('45.67')).toBe('45.67')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('developerStandardize', () => {
|
|
96
|
+
it('returns empty array when data is falsy', () => {
|
|
97
|
+
expect(transformer.developerStandardize(null, {})).toEqual([])
|
|
98
|
+
expect(transformer.developerStandardize(undefined, {})).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns undefined when description is missing', () => {
|
|
102
|
+
const data = [{ a: 1 }]
|
|
103
|
+
expect(transformer.developerStandardize(data, null)).toBeUndefined()
|
|
104
|
+
expect(transformer.developerStandardize(data, undefined)).toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns undefined when description is incomplete', () => {
|
|
108
|
+
const data = [{ a: 1 }]
|
|
109
|
+
expect(transformer.developerStandardize(data, {})).toBeUndefined()
|
|
110
|
+
expect(transformer.developerStandardize(data, { horizontal: true })).toBeUndefined()
|
|
111
|
+
expect(transformer.developerStandardize(data, { series: true })).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('handles horizontal data without series', () => {
|
|
115
|
+
const data = [
|
|
116
|
+
{ category: 'A', val1: 10, val2: 20 },
|
|
117
|
+
{ category: 'B', val1: 30, val2: 40 }
|
|
118
|
+
]
|
|
119
|
+
const description = { horizontal: true, series: false }
|
|
120
|
+
const result = transformer.developerStandardize(data, description)
|
|
121
|
+
expect(result).toBeDefined()
|
|
122
|
+
expect(Array.isArray(result)).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getDateRenderFormat, formatDate } from '../cove/date'
|
|
3
|
+
|
|
4
|
+
const NBSP = '\u00A0'
|
|
5
|
+
|
|
6
|
+
describe('getDateRenderFormat', () => {
|
|
7
|
+
it('replaces space between %b. and %-d with NBSP', () => {
|
|
8
|
+
expect(getDateRenderFormat('%b. %-d %Y')).toBe(`%b.${NBSP}%-d %Y`)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('replaces space between %b and %-d with NBSP', () => {
|
|
12
|
+
expect(getDateRenderFormat('%b %-d %Y')).toBe(`%b${NBSP}%-d %Y`)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('replaces space between %B and %-d (full month name)', () => {
|
|
16
|
+
expect(getDateRenderFormat('%B %-d, %Y')).toBe(`%B${NBSP}%-d, %Y`)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('leaves format unchanged when no month-day space pattern', () => {
|
|
20
|
+
expect(getDateRenderFormat('%Y-%m-%d')).toBe('%Y-%m-%d')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not replace existing NBSP (idempotent)', () => {
|
|
24
|
+
const alreadyNbsp = `%b.${NBSP}%-d %Y`
|
|
25
|
+
expect(getDateRenderFormat(alreadyNbsp)).toBe(alreadyNbsp)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns undefined for undefined input', () => {
|
|
29
|
+
expect(getDateRenderFormat(undefined)).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns empty string for empty string input', () => {
|
|
33
|
+
expect(getDateRenderFormat('')).toBe('')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('formatDate', () => {
|
|
38
|
+
it('renders date with NBSP when format has space between month and day', () => {
|
|
39
|
+
const date = new Date(2025, 0, 15) // Jan 15, 2025
|
|
40
|
+
const result = formatDate('%b. %-d %Y', date)
|
|
41
|
+
expect(result).toContain(NBSP)
|
|
42
|
+
expect(result).toBe(`Jan.${NBSP}15 2025`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('strips trailing period from "May." when using %b. format', () => {
|
|
46
|
+
const date = new Date(2025, 4, 15) // May 15, 2025
|
|
47
|
+
const result = formatDate('%b. %-d, %Y', date)
|
|
48
|
+
expect(result).not.toContain('May.')
|
|
49
|
+
expect(result).toContain('May')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('leaves "May" alone when format does not use %b.', () => {
|
|
53
|
+
const date = new Date(2025, 4, 15) // May 15, 2025
|
|
54
|
+
const result = formatDate('%b %-d, %Y', date)
|
|
55
|
+
expect(result).toContain('May')
|
|
56
|
+
expect(result).not.toContain('May.')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not strip period from other months when using %b. format', () => {
|
|
60
|
+
const date = new Date(2025, 0, 15) // Jan 15, 2025
|
|
61
|
+
const result = formatDate('%b. %-d, %Y', date)
|
|
62
|
+
expect(result).toContain('Jan.')
|
|
63
|
+
})
|
|
64
|
+
})
|