@cdc/chart 4.26.4 → 4.26.5
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/CONFIG.md +349 -29
- package/LICENSE +201 -0
- package/dist/cdcchart.js +34247 -32805
- package/examples/feature/heatmap/average-age-categorical.json +313 -0
- package/examples/feature/heatmap/calendar-heatmap.json +134 -0
- package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
- package/examples/feature/heatmap/sparse-aggregation.json +158 -0
- package/examples/private/bump-chart.json +1116 -0
- package/examples/private/test-123.json +624 -0
- package/package.json +4 -3
- package/src/CdcChartComponent.tsx +135 -54
- package/src/_stories/Chart.AltText.stories.tsx +136 -0
- package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
- package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
- package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
- package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
- package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
- package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
- package/src/_stories/_mock/alt_text_metadata.json +38 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
- package/src/_stories/_mock/heatmap.json +156 -0
- package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
- package/src/_stories/_mock/sankey_mock.json +78 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
- package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
- package/src/components/Axis/LeftAxis.tsx +14 -11
- package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
- package/src/components/Brush/BrushSelector.tsx +88 -187
- package/src/components/Brush/brushInteraction.ts +22 -0
- package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
- package/src/components/EditorPanel/EditorPanel.tsx +310 -49
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
- package/src/components/HeatMap/components/HeatMap.tsx +762 -0
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
- package/src/components/HeatMap/heatmap.constants.ts +13 -0
- package/src/components/HeatMap/heatmap.css +71 -0
- package/src/components/HeatMap/helpers/index.ts +261 -0
- package/src/components/HeatMap/index.tsx +5 -0
- package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
- package/src/components/HeatMap/tests/helpers.test.ts +167 -0
- package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
- package/src/components/LineChart/helpers.test.ts +345 -0
- package/src/components/LineChart/helpers.ts +15 -5
- package/src/components/LineChart/index.tsx +7 -1
- package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
- package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
- package/src/components/LinearChart.tsx +58 -37
- package/src/components/PieChart/PieChart.tsx +4 -1
- package/src/components/RadarChart/RadarChart.tsx +3 -1
- package/src/components/Regions/components/Regions.tsx +195 -41
- package/src/components/Sankey/components/Sankey.tsx +7 -3
- package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
- package/src/components/SmallMultiples/SmallMultiples.css +12 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
- package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
- package/src/components/Sparkline/components/SparkLine.tsx +4 -9
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
- package/src/data/initial-state.js +11 -0
- package/src/helpers/axisLabelFontSize.ts +8 -0
- package/src/helpers/bumpChartTooltip.ts +54 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
- package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
- package/src/helpers/getMinMax.ts +2 -3
- package/src/helpers/getYAxisAutoPadding.ts +8 -6
- package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
- package/src/helpers/handleChartAriaLabels.ts +23 -10
- package/src/helpers/seriesHighlight.ts +26 -0
- package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
- package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
- package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
- package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
- package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
- package/src/helpers/tests/seriesHighlight.test.ts +61 -0
- package/src/hooks/tests/useScales.test.tsx +105 -0
- package/src/hooks/useScales.ts +19 -9
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +32 -49
- package/src/types/ChartConfig.ts +18 -0
- package/src/types/ChartContext.ts +2 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { expect } from 'storybook/test'
|
|
3
|
+
import Chart from '../CdcChart'
|
|
4
|
+
import heatMapAverageAgeCategoricalConfig from './_mock/heatmap-average-age-categorical.json'
|
|
5
|
+
import heatMapSparseAggregationConfig from '../../examples/feature/heatmap/sparse-aggregation.json'
|
|
6
|
+
import heatMapCellValuesBottomAxisConfig from '../../examples/feature/heatmap/cell-values-bottom-axis.json'
|
|
7
|
+
import heatMapCalendarConfig from '../../examples/feature/heatmap/calendar-heatmap.json'
|
|
8
|
+
import { assertVisualizationRendered, waitForPresence } from '@cdc/core/helpers/testing'
|
|
9
|
+
|
|
10
|
+
const meta: Meta<typeof Chart> = {
|
|
11
|
+
title: 'Components/Templates/Chart/HeatMap',
|
|
12
|
+
component: Chart
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof Chart>
|
|
16
|
+
|
|
17
|
+
const heatMapTopYAxisLabelConfig = {
|
|
18
|
+
...heatMapAverageAgeCategoricalConfig,
|
|
19
|
+
yAxis: {
|
|
20
|
+
...heatMapAverageAgeCategoricalConfig.yAxis,
|
|
21
|
+
titlePlacement: 'top'
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const getTooltipHtmlValues = (canvasElement: HTMLElement) =>
|
|
26
|
+
Array.from(canvasElement.querySelectorAll('[data-tooltip-html]')).map(
|
|
27
|
+
element => element.getAttribute('data-tooltip-html') || ''
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const getSvgTextBox = (svg: SVGSVGElement, value: string) =>
|
|
31
|
+
Array.from(svg.querySelectorAll('text'))
|
|
32
|
+
.find(text => text.textContent === value)
|
|
33
|
+
?.getBoundingClientRect()
|
|
34
|
+
|
|
35
|
+
const getSvgText = (svg: SVGSVGElement, value: string) =>
|
|
36
|
+
Array.from(svg.querySelectorAll('text')).find(text => text.textContent === value)
|
|
37
|
+
|
|
38
|
+
const getTranslateY = (element: Element | null | undefined) => {
|
|
39
|
+
const transform = element?.getAttribute('transform') || ''
|
|
40
|
+
const match = transform.match(/translate\([^,]+,\s*([^)]+)\)/)
|
|
41
|
+
return match ? Number(match[1]) : 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const getElementBox = (canvasElement: HTMLElement, selector: string) =>
|
|
45
|
+
canvasElement.querySelector(selector)?.getBoundingClientRect()
|
|
46
|
+
|
|
47
|
+
export const HeatMap_Demo: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
config: heatMapSparseAggregationConfig,
|
|
50
|
+
isEditor: false
|
|
51
|
+
},
|
|
52
|
+
play: async ({ canvasElement }) => {
|
|
53
|
+
await assertVisualizationRendered(canvasElement)
|
|
54
|
+
await waitForPresence('.visx-heatmap-rect', canvasElement)
|
|
55
|
+
|
|
56
|
+
const cells = canvasElement.querySelectorAll('.visx-heatmap-rect')
|
|
57
|
+
const tooltipHtmlValues = getTooltipHtmlValues(canvasElement)
|
|
58
|
+
const firstCell = cells[0]
|
|
59
|
+
const legendBox = getElementBox(canvasElement, '.cdc-heatmap__legend')
|
|
60
|
+
const heatMapBox = getElementBox(canvasElement, '.cdc-heatmap')
|
|
61
|
+
const dataTableBox = getElementBox(canvasElement, '.data-table-container')
|
|
62
|
+
const tableGap = (dataTableBox?.top || 0) - (heatMapBox?.bottom || 0)
|
|
63
|
+
|
|
64
|
+
expect(cells.length).toBe(9)
|
|
65
|
+
expect(firstCell).toHaveAttribute('tabindex', '0')
|
|
66
|
+
expect(firstCell).toHaveAttribute('aria-label')
|
|
67
|
+
expect(legendBox?.bottom).toBeLessThanOrEqual((heatMapBox?.top || 0) + 2)
|
|
68
|
+
expect(tableGap).toBeGreaterThanOrEqual(0)
|
|
69
|
+
expect(tableGap).toBeLessThan(80)
|
|
70
|
+
expect(tooltipHtmlValues.some(html => html.includes('tooltip-heading'))).toBe(true)
|
|
71
|
+
expect(tooltipHtmlValues.some(html => html.includes('Aggregated Rows: 2'))).toBe(true)
|
|
72
|
+
expect(tooltipHtmlValues.some(html => html.includes('Notes: Multiple values'))).toBe(true)
|
|
73
|
+
expect(tooltipHtmlValues.some(html => html.includes('No data'))).toBe(true)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const HeatMap_Average_Age_Categorical_Demo: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
config: heatMapAverageAgeCategoricalConfig,
|
|
80
|
+
isEditor: false
|
|
81
|
+
},
|
|
82
|
+
play: async ({ canvasElement }) => {
|
|
83
|
+
await assertVisualizationRendered(canvasElement)
|
|
84
|
+
await waitForPresence('.visx-heatmap-rect', canvasElement)
|
|
85
|
+
|
|
86
|
+
const cells = canvasElement.querySelectorAll('.visx-heatmap-rect')
|
|
87
|
+
const tooltipHtmlValues = getTooltipHtmlValues(canvasElement)
|
|
88
|
+
const firstCell = cells[0]
|
|
89
|
+
const firstCellAriaLabel = firstCell.getAttribute('aria-label') || ''
|
|
90
|
+
const chartSvg = firstCell.ownerSVGElement as SVGSVGElement
|
|
91
|
+
const firstCellBox = firstCell.getBoundingClientRect()
|
|
92
|
+
const lastCellBox = cells[cells.length - 1].getBoundingClientRect()
|
|
93
|
+
const cityLabelBox = getSvgTextBox(chartSvg, 'Atlanta')
|
|
94
|
+
const rowAxisTitle = getSvgText(chartSvg, 'City')
|
|
95
|
+
const columnLabelBox = getSvgTextBox(chartSvg, 'Urban Core')
|
|
96
|
+
const svgBox = chartSvg.getBoundingClientRect()
|
|
97
|
+
const blockLeft = Math.min(cityLabelBox?.left || firstCellBox.left, firstCellBox.left)
|
|
98
|
+
const blockRight = lastCellBox.right
|
|
99
|
+
const blockCenterDelta = Math.abs((blockLeft + blockRight) / 2 - (svgBox.left + svgBox.width / 2))
|
|
100
|
+
const rowLabelGap = cityLabelBox ? firstCellBox.left - cityLabelBox.right : 0
|
|
101
|
+
const columnLabelGap = columnLabelBox ? firstCellBox.top - columnLabelBox.bottom : 0
|
|
102
|
+
|
|
103
|
+
expect(cells.length).toBe(60)
|
|
104
|
+
expect(firstCellAriaLabel).toContain('Community Type: Urban Core')
|
|
105
|
+
expect(firstCellAriaLabel).toContain('City: Atlanta')
|
|
106
|
+
expect(rowLabelGap).toBeGreaterThan(16)
|
|
107
|
+
expect(rowLabelGap).toBeLessThan(80)
|
|
108
|
+
expect(columnLabelGap).toBeGreaterThan(0)
|
|
109
|
+
expect(rowAxisTitle?.getAttribute('transform')).toContain('rotate(-90)')
|
|
110
|
+
expect(columnLabelBox?.top).toBeGreaterThanOrEqual(svgBox.top - 1)
|
|
111
|
+
expect(blockCenterDelta).toBeLessThan(140)
|
|
112
|
+
expect(tooltipHtmlValues.some(html => html.includes('Community Type: Urban Core'))).toBe(true)
|
|
113
|
+
expect(tooltipHtmlValues.some(html => html.includes('Average age: 34'))).toBe(true)
|
|
114
|
+
expect(tooltipHtmlValues.some(html => html.includes('Notes: Dense city center'))).toBe(true)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const HeatMap_Top_Y_Axis_Label_Demo: Story = {
|
|
119
|
+
args: {
|
|
120
|
+
config: heatMapTopYAxisLabelConfig,
|
|
121
|
+
isEditor: false
|
|
122
|
+
},
|
|
123
|
+
play: async ({ canvasElement }) => {
|
|
124
|
+
await assertVisualizationRendered(canvasElement)
|
|
125
|
+
await waitForPresence('.visx-heatmap-rect', canvasElement)
|
|
126
|
+
|
|
127
|
+
const chartSvg = canvasElement.querySelector('.cdc-heatmap__svg') as SVGSVGElement
|
|
128
|
+
const rowAxisTitle = getSvgText(chartSvg, 'City')
|
|
129
|
+
const columnAxisTitle = getSvgText(chartSvg, 'Community Type')
|
|
130
|
+
|
|
131
|
+
expect(rowAxisTitle?.getAttribute('transform')).toBeNull()
|
|
132
|
+
expect(rowAxisTitle?.getAttribute('y')).toBe(columnAxisTitle?.getAttribute('y'))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const HeatMap_Cell_Values_And_Binned_Legend: Story = {
|
|
137
|
+
args: {
|
|
138
|
+
config: heatMapCellValuesBottomAxisConfig,
|
|
139
|
+
isEditor: false
|
|
140
|
+
},
|
|
141
|
+
play: async ({ canvasElement }) => {
|
|
142
|
+
await assertVisualizationRendered(canvasElement)
|
|
143
|
+
await waitForPresence('.visx-heatmap-rect', canvasElement)
|
|
144
|
+
await waitForPresence('.cdc-heatmap__cell-value', canvasElement)
|
|
145
|
+
|
|
146
|
+
const cellValues = canvasElement.querySelectorAll('.cdc-heatmap__cell-value')
|
|
147
|
+
const legendItems = canvasElement.querySelectorAll('.cdc-heatmap__legend .legend-item')
|
|
148
|
+
const legendBox = getElementBox(canvasElement, '.cdc-heatmap__legend')
|
|
149
|
+
const heatMapBox = getElementBox(canvasElement, '.cdc-heatmap')
|
|
150
|
+
const dataTableBox = getElementBox(canvasElement, '.data-table-container')
|
|
151
|
+
const tableGap = (dataTableBox?.top || 0) - (legendBox?.bottom || 0)
|
|
152
|
+
|
|
153
|
+
expect(canvasElement.querySelectorAll('.visx-heatmap-rect').length).toBe(20)
|
|
154
|
+
expect(canvasElement.querySelector('.visx-axis-bottom')).toBeTruthy()
|
|
155
|
+
expect(heatMapBox?.bottom).toBeLessThanOrEqual((legendBox?.top || 0) + 2)
|
|
156
|
+
expect(tableGap).toBeGreaterThanOrEqual(0)
|
|
157
|
+
expect(tableGap).toBeLessThan(80)
|
|
158
|
+
expect(cellValues.length).toBeGreaterThan(0)
|
|
159
|
+
expect(legendItems.length).toBeGreaterThan(0)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const HeatMap_Calendar_Demo: Story = {
|
|
164
|
+
args: {
|
|
165
|
+
config: heatMapCalendarConfig,
|
|
166
|
+
isEditor: false
|
|
167
|
+
},
|
|
168
|
+
play: async ({ canvasElement }) => {
|
|
169
|
+
await assertVisualizationRendered(canvasElement)
|
|
170
|
+
await waitForPresence('.visx-heatmap-rect', canvasElement)
|
|
171
|
+
|
|
172
|
+
const cells = Array.from(canvasElement.querySelectorAll('.visx-heatmap-rect'))
|
|
173
|
+
const chartSvg = cells[0].ownerSVGElement as SVGSVGElement
|
|
174
|
+
const rowAxisTitle = getSvgText(chartSvg, 'Month')
|
|
175
|
+
const gridTop = Math.min(...cells.map(cell => Number(cell.getAttribute('y'))))
|
|
176
|
+
const gridBottom = Math.max(
|
|
177
|
+
...cells.map(cell => Number(cell.getAttribute('y')) + Number(cell.getAttribute('height')))
|
|
178
|
+
)
|
|
179
|
+
const gridCenter = (gridTop + gridBottom) / 2
|
|
180
|
+
|
|
181
|
+
expect(cells.length).toBe(372)
|
|
182
|
+
expect(rowAxisTitle?.getAttribute('transform')).toContain('rotate(-90)')
|
|
183
|
+
expect(Math.abs(getTranslateY(rowAxisTitle) - gridCenter)).toBeLessThanOrEqual(1)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default meta
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import Chart from '../CdcChartComponent'
|
|
3
|
+
import sankeyConfig from './_mock/sankey_mock.json'
|
|
4
|
+
import { assertVisualizationRendered } from '@cdc/core/helpers/testing'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Chart> = {
|
|
7
|
+
title: 'Components/Templates/Chart/Sankey',
|
|
8
|
+
component: Chart
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof Chart>
|
|
13
|
+
|
|
14
|
+
export const Sankey_Basic: Story = {
|
|
15
|
+
args: {
|
|
16
|
+
config: {
|
|
17
|
+
...sankeyConfig,
|
|
18
|
+
title: 'Patient Care Pathway'
|
|
19
|
+
},
|
|
20
|
+
isEditor: false
|
|
21
|
+
},
|
|
22
|
+
play: async ({ canvasElement }) => {
|
|
23
|
+
await assertVisualizationRendered(canvasElement)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const Sankey_WithStoryNodes: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
config: {
|
|
30
|
+
...sankeyConfig,
|
|
31
|
+
title: 'Care Pathway with Story Nodes',
|
|
32
|
+
data: [
|
|
33
|
+
{
|
|
34
|
+
links: sankeyConfig.data[0].links,
|
|
35
|
+
tooltips: true,
|
|
36
|
+
storyNodeText: [
|
|
37
|
+
{ StoryNode: 'Screened', segmentTextBefore: 'Total Screened', segmentTextAfter: 'participants' },
|
|
38
|
+
{ StoryNode: 'Recovered', segmentTextBefore: 'Fully', segmentTextAfter: 'patients' }
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
isEditor: false
|
|
44
|
+
},
|
|
45
|
+
play: async ({ canvasElement }) => {
|
|
46
|
+
await assertVisualizationRendered(canvasElement)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Sankey_TooltipsDisabled: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
config: {
|
|
53
|
+
...sankeyConfig,
|
|
54
|
+
title: 'Sankey — Tooltips Disabled',
|
|
55
|
+
enableTooltips: false
|
|
56
|
+
},
|
|
57
|
+
isEditor: false
|
|
58
|
+
},
|
|
59
|
+
play: async ({ canvasElement }) => {
|
|
60
|
+
await assertVisualizationRendered(canvasElement)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -5,6 +5,7 @@ import smallMultiplesBigDataBars from './_mock/small_multiples/small_multiples_b
|
|
|
5
5
|
import smallMultiplesLinesColors from './_mock/small_multiples/small_multiples_lines_colors.json'
|
|
6
6
|
import smallMultiplesLines from './_mock/small_multiples/small_multiples_lines.json'
|
|
7
7
|
import smallMultiplesStackedBars from './_mock/small_multiples/small_multiples_stacked_bars.json'
|
|
8
|
+
import { editConfigKeys } from '@cdc/core/helpers/configHelpers'
|
|
8
9
|
import { assertVisualizationRendered, waitForPresence } from '@cdc/core/helpers/testing'
|
|
9
10
|
import { expect } from 'storybook/test'
|
|
10
11
|
|
|
@@ -62,6 +63,34 @@ export const smallMultiples_Lines: Story = {
|
|
|
62
63
|
await assertVisualizationRendered(canvasElement)
|
|
63
64
|
}
|
|
64
65
|
}
|
|
66
|
+
export const smallMultiples_TopYAxisTitle: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
config: editConfigKeys(smallMultiplesLines, [
|
|
69
|
+
{ path: ['yAxis', 'label'], value: 'Percentage of emergency department visits' },
|
|
70
|
+
{ path: ['runtime', 'yAxis', 'label'], value: 'Percentage of emergency department visits' },
|
|
71
|
+
{ path: ['yAxis', 'inlineLabel'], value: ' percent' },
|
|
72
|
+
{ path: ['yAxis', 'titlePlacement'], value: 'top' }
|
|
73
|
+
]),
|
|
74
|
+
isEditor: false
|
|
75
|
+
},
|
|
76
|
+
play: async ({ canvasElement }) => {
|
|
77
|
+
await assertVisualizationRendered(canvasElement)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export const smallMultiples_TopYAxisTitle2: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
config: editConfigKeys(smallMultiplesLines, [
|
|
83
|
+
{ path: ['yAxis', 'label'], value: 'Percentage of emergency department visits' },
|
|
84
|
+
{ path: ['runtime', 'yAxis', 'label'], value: 'Percentage of emergency department visits' },
|
|
85
|
+
{ path: ['yAxis', 'inlineLabel'], value: '%' },
|
|
86
|
+
{ path: ['yAxis', 'titlePlacement'], value: 'top' }
|
|
87
|
+
]),
|
|
88
|
+
isEditor: false
|
|
89
|
+
},
|
|
90
|
+
play: async ({ canvasElement }) => {
|
|
91
|
+
await assertVisualizationRendered(canvasElement)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
65
94
|
export const smallMultiples_StackedBars: Story = {
|
|
66
95
|
args: {
|
|
67
96
|
config: smallMultiplesStackedBars,
|
|
@@ -61,3 +61,16 @@ export const Paired_Bar: Story = {
|
|
|
61
61
|
await assertVisualizationRendered(canvasElement)
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
export const Top_Y_Axis_Title: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
config: editConfigKeys(longXLabelsConfig, [
|
|
68
|
+
{ path: ['yAxis', 'label'], value: 'Number of emergency department visits' },
|
|
69
|
+
{ path: ['runtime', 'yAxis', 'label'], value: 'Number of emergency department visits' },
|
|
70
|
+
{ path: ['yAxis', 'titlePlacement'], value: 'top' }
|
|
71
|
+
])
|
|
72
|
+
},
|
|
73
|
+
play: async ({ canvasElement }) => {
|
|
74
|
+
await assertVisualizationRendered(canvasElement)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -848,6 +848,61 @@ export const BarLeftValueAxisTests: Story = {
|
|
|
848
848
|
}
|
|
849
849
|
)
|
|
850
850
|
|
|
851
|
+
const getAxisTitlePlacementVisualization = () => {
|
|
852
|
+
const chartContainer = canvasElement.querySelector('.cove-visualization__body, .chart-container, .visualization')
|
|
853
|
+
const svg = chartContainer?.querySelector('svg') || canvasElement.querySelector('svg:not(.icon)')
|
|
854
|
+
const sideLabelCandidates = svg ? Array.from(svg.querySelectorAll('text.y-label')) : []
|
|
855
|
+
const sideLabel = sideLabelCandidates.find(el => (el.textContent?.trim() || '').length > 0) || null
|
|
856
|
+
const topLabel = chartContainer?.querySelector('.y-axis-top-title')
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
hasSideLabel: !!sideLabel,
|
|
860
|
+
sideLabelTransform: sideLabel?.getAttribute('transform') || '',
|
|
861
|
+
sideLabelText: sideLabel?.textContent?.trim() || '',
|
|
862
|
+
hasTopLabel: !!topLabel,
|
|
863
|
+
topLabelText: topLabel?.textContent?.trim() || ''
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const titlePlacementSelect = canvas.getByLabelText(/label placement/i) as HTMLSelectElement
|
|
868
|
+
|
|
869
|
+
expect(yAxisLabelInput.getAttribute('maxlength')).toBe('35')
|
|
870
|
+
|
|
871
|
+
await performAndAssert(
|
|
872
|
+
'Switch Title Placement to Top',
|
|
873
|
+
getAxisTitlePlacementVisualization,
|
|
874
|
+
async () => {
|
|
875
|
+
await userEvent.selectOptions(titlePlacementSelect, 'top')
|
|
876
|
+
},
|
|
877
|
+
(before, after) => {
|
|
878
|
+
expect(titlePlacementSelect.value).toBe('top')
|
|
879
|
+
expect(yAxisLabelInput.getAttribute('maxlength')).toBeNull()
|
|
880
|
+
expect(after.hasTopLabel).toBe(true)
|
|
881
|
+
expect(after.topLabelText).toBe('Y-Axis')
|
|
882
|
+
expect(after.hasSideLabel).toBe(false)
|
|
883
|
+
|
|
884
|
+
return true
|
|
885
|
+
}
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
await performAndAssert(
|
|
889
|
+
'Switch Title Placement back to Side',
|
|
890
|
+
getAxisTitlePlacementVisualization,
|
|
891
|
+
async () => {
|
|
892
|
+
await userEvent.selectOptions(titlePlacementSelect, 'side')
|
|
893
|
+
},
|
|
894
|
+
(before, after) => {
|
|
895
|
+
expect(titlePlacementSelect.value).toBe('side')
|
|
896
|
+
expect(yAxisLabelInput.getAttribute('maxlength')).toBe('35')
|
|
897
|
+
expect(after.hasTopLabel).toBe(false)
|
|
898
|
+
expect(after.hasSideLabel).toBe(true)
|
|
899
|
+
expect(after.sideLabelText).toBe('Y-Axis')
|
|
900
|
+
expect(after.sideLabelTransform).toContain('rotate(-90)')
|
|
901
|
+
|
|
902
|
+
return true
|
|
903
|
+
}
|
|
904
|
+
)
|
|
905
|
+
|
|
851
906
|
// ============================================================================
|
|
852
907
|
// TEST: Inline Label Field
|
|
853
908
|
// Tests visualization output changes when entering custom inline label text
|
|
@@ -1529,9 +1584,13 @@ export const BarRegionsSectionTests: Story = {
|
|
|
1529
1584
|
const textColors = canvas.queryAllByLabelText(/text color/i)
|
|
1530
1585
|
const backgrounds = canvas.queryAllByLabelText(/background/i)
|
|
1531
1586
|
const minRegionTypes = canvas.queryAllByLabelText(/minimum region type/i)
|
|
1587
|
+
const textColor = textColors[0] as HTMLInputElement | undefined
|
|
1588
|
+
const background = backgrounds[0] as HTMLInputElement | undefined
|
|
1532
1589
|
|
|
1533
1590
|
return {
|
|
1534
1591
|
regionCount: regionLabels.length,
|
|
1592
|
+
textColor: textColor?.placeholder,
|
|
1593
|
+
background: background?.placeholder,
|
|
1535
1594
|
hasFields:
|
|
1536
1595
|
regionLabels.length > 0 && textColors.length > 0 && backgrounds.length > 0 && minRegionTypes.length > 0
|
|
1537
1596
|
}
|
|
@@ -1544,6 +1603,10 @@ export const BarRegionsSectionTests: Story = {
|
|
|
1544
1603
|
// Should now have 1 region with all fields
|
|
1545
1604
|
expect(after.regionCount).toBe(1)
|
|
1546
1605
|
expect(after.hasFields).toBe(true)
|
|
1606
|
+
expect(after.textColor).toBe(
|
|
1607
|
+
getComputedStyle(document.documentElement).getPropertyValue('--cool-gray-90').trim()
|
|
1608
|
+
)
|
|
1609
|
+
expect(after.background).toBe('#71767a')
|
|
1547
1610
|
|
|
1548
1611
|
return true
|
|
1549
1612
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { expect, waitFor } from 'storybook/test'
|
|
3
|
+
import Chart from '../CdcChartComponent'
|
|
4
|
+
import brushEnabledConfig from './_mock/brush_enabled.json'
|
|
5
|
+
import { assertVisualizationRendered } from '@cdc/core/helpers/testing'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof Chart> = {
|
|
8
|
+
title: 'Components/Templates/Chart/BrushSlider/Interactions',
|
|
9
|
+
component: Chart
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof Chart>
|
|
15
|
+
|
|
16
|
+
type BrushSelectionState = {
|
|
17
|
+
x: string | null
|
|
18
|
+
width: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getBrushSelection = (canvasElement: HTMLElement) => {
|
|
22
|
+
const selection = canvasElement.querySelector('.visx-brush-selection') as SVGRectElement | null
|
|
23
|
+
expect(selection).toBeTruthy()
|
|
24
|
+
return selection!
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const readBrushSelectionState = (canvasElement: HTMLElement): BrushSelectionState => {
|
|
28
|
+
const selection = getBrushSelection(canvasElement)
|
|
29
|
+
return {
|
|
30
|
+
x: selection.getAttribute('x'),
|
|
31
|
+
width: selection.getAttribute('width')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const getBrushPoint = (canvasElement: HTMLElement, x: number) => {
|
|
36
|
+
const overlay = canvasElement.querySelector('.visx-brush-overlay') as SVGRectElement | null
|
|
37
|
+
expect(overlay).toBeTruthy()
|
|
38
|
+
|
|
39
|
+
const rect = overlay!.getBoundingClientRect()
|
|
40
|
+
return {
|
|
41
|
+
target: overlay!,
|
|
42
|
+
clientX: rect.left + x,
|
|
43
|
+
clientY: rect.top + rect.height / 2
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dispatchPointer = (target: Element, type: string, clientX: number, clientY: number, buttons = 1) => {
|
|
48
|
+
target.dispatchEvent(
|
|
49
|
+
new PointerEvent(type, {
|
|
50
|
+
bubbles: true,
|
|
51
|
+
cancelable: true,
|
|
52
|
+
clientX,
|
|
53
|
+
clientY,
|
|
54
|
+
buttons,
|
|
55
|
+
pointerId: 1,
|
|
56
|
+
pointerType: 'mouse'
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const settleInteraction = async () => {
|
|
62
|
+
await new Promise<void>(resolve => {
|
|
63
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const OutsideSelectionInteractionsBlocked: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
config: brushEnabledConfig,
|
|
70
|
+
isEditor: false
|
|
71
|
+
},
|
|
72
|
+
parameters: {
|
|
73
|
+
docs: {
|
|
74
|
+
description: {
|
|
75
|
+
story: 'Outside-selection clicks and drags are blocked before they reach the visx brush overlay.'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
play: async ({ canvasElement }) => {
|
|
80
|
+
await assertVisualizationRendered(canvasElement)
|
|
81
|
+
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(canvasElement.querySelector('.visx-brush-selection')).toBeTruthy()
|
|
84
|
+
expect(canvasElement.querySelector('.visx-brush-overlay')).toBeTruthy()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const initial = readBrushSelectionState(canvasElement)
|
|
88
|
+
const selectionX = Number(initial.x)
|
|
89
|
+
const selectionWidth = Number(initial.width)
|
|
90
|
+
|
|
91
|
+
const outsideLeft = getBrushPoint(canvasElement, Math.max(1, selectionX - 40))
|
|
92
|
+
dispatchPointer(outsideLeft.target, 'pointerdown', outsideLeft.clientX, outsideLeft.clientY)
|
|
93
|
+
dispatchPointer(outsideLeft.target, 'pointerup', outsideLeft.clientX, outsideLeft.clientY, 0)
|
|
94
|
+
outsideLeft.target.dispatchEvent(
|
|
95
|
+
new MouseEvent('click', {
|
|
96
|
+
bubbles: true,
|
|
97
|
+
cancelable: true,
|
|
98
|
+
clientX: outsideLeft.clientX,
|
|
99
|
+
clientY: outsideLeft.clientY
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
await settleInteraction()
|
|
103
|
+
expect(readBrushSelectionState(canvasElement)).toEqual(initial)
|
|
104
|
+
|
|
105
|
+
const outsideRight = getBrushPoint(canvasElement, selectionX + selectionWidth + 40)
|
|
106
|
+
dispatchPointer(outsideRight.target, 'pointerdown', outsideRight.clientX, outsideRight.clientY)
|
|
107
|
+
dispatchPointer(outsideRight.target, 'pointermove', outsideRight.clientX + 80, outsideRight.clientY)
|
|
108
|
+
dispatchPointer(outsideRight.target, 'pointerup', outsideRight.clientX + 80, outsideRight.clientY, 0)
|
|
109
|
+
await settleInteraction()
|
|
110
|
+
expect(readBrushSelectionState(canvasElement)).toEqual(initial)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import Chart from '../CdcChartComponent'
|
|
3
3
|
import brushEnabledConfig from './_mock/brush_enabled.json'
|
|
4
|
+
import pertussisConfig from './_mock/reported_pertussis_cases.json'
|
|
4
5
|
import { assertVisualizationRendered } from '@cdc/core/helpers/testing'
|
|
5
6
|
|
|
6
7
|
const meta: Meta<typeof Chart> = {
|
|
@@ -36,16 +37,24 @@ export const BrushSliderEnabled: Story = {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
export const
|
|
40
|
+
export const BrushDynamicYAxis: Story = {
|
|
40
41
|
args: {
|
|
41
|
-
config:
|
|
42
|
-
|
|
42
|
+
config: {
|
|
43
|
+
...pertussisConfig,
|
|
44
|
+
xAxis: {
|
|
45
|
+
...pertussisConfig.xAxis,
|
|
46
|
+
brushActive: true,
|
|
47
|
+
brushDynamicYAxis: true,
|
|
48
|
+
brushDefaultRecentDateCount: undefined
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
isEditor: false
|
|
43
52
|
},
|
|
44
53
|
parameters: {
|
|
45
54
|
docs: {
|
|
46
55
|
description: {
|
|
47
56
|
story:
|
|
48
|
-
'
|
|
57
|
+
'Reported pertussis cases (1922-2025) with Dynamic Y-Axis enabled. The Y-axis rescales to fit only the data visible in the current brush selection. Drag the brush to the 1970s-1990s low period (~1K-7K cases) then to the 1920s-1940s peak (~100K-265K cases) to see the Y-axis adjust dramatically.'
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "4.26.4",
|
|
3
|
+
"type": "chart",
|
|
4
|
+
"visualizationType": "Line",
|
|
5
|
+
"title": "COVID-19 Test Positivity",
|
|
6
|
+
"showTitle": true,
|
|
7
|
+
"animate": false,
|
|
8
|
+
"orientation": "vertical",
|
|
9
|
+
"dataMetadata": {
|
|
10
|
+
"altDescription": "Line chart showing COVID-19 test positivity rising from 4% to 12% over the last eight weeks, which may indicate growing community spread.",
|
|
11
|
+
"lastUpdated": "2024-09-21",
|
|
12
|
+
"source": "CDC NREVSS"
|
|
13
|
+
},
|
|
14
|
+
"data": [
|
|
15
|
+
{ "week": "Aug 3", "positivity": 4.2 },
|
|
16
|
+
{ "week": "Aug 10", "positivity": 4.8 },
|
|
17
|
+
{ "week": "Aug 17", "positivity": 5.5 },
|
|
18
|
+
{ "week": "Aug 24", "positivity": 6.3 },
|
|
19
|
+
{ "week": "Aug 31", "positivity": 7.4 },
|
|
20
|
+
{ "week": "Sep 7", "positivity": 8.9 },
|
|
21
|
+
{ "week": "Sep 14", "positivity": 10.6 },
|
|
22
|
+
{ "week": "Sep 21", "positivity": 12.0 }
|
|
23
|
+
],
|
|
24
|
+
"xAxis": {
|
|
25
|
+
"dataKey": "week",
|
|
26
|
+
"type": "categorical",
|
|
27
|
+
"label": "Week Ending"
|
|
28
|
+
},
|
|
29
|
+
"yAxis": {
|
|
30
|
+
"label": "% Positive",
|
|
31
|
+
"numTicks": "5"
|
|
32
|
+
},
|
|
33
|
+
"series": [
|
|
34
|
+
{ "dataKey": "positivity", "type": "Line", "axis": "Left", "tooltip": true }
|
|
35
|
+
],
|
|
36
|
+
"legend": { "hide": true },
|
|
37
|
+
"table": { "show": true, "label": "Data Table" }
|
|
38
|
+
}
|