@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.
Files changed (93) hide show
  1. package/CONFIG.md +349 -29
  2. package/LICENSE +201 -0
  3. package/dist/cdcchart.js +34247 -32805
  4. package/examples/feature/heatmap/average-age-categorical.json +313 -0
  5. package/examples/feature/heatmap/calendar-heatmap.json +134 -0
  6. package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
  7. package/examples/feature/heatmap/sparse-aggregation.json +158 -0
  8. package/examples/private/bump-chart.json +1116 -0
  9. package/examples/private/test-123.json +624 -0
  10. package/package.json +4 -3
  11. package/src/CdcChartComponent.tsx +135 -54
  12. package/src/_stories/Chart.AltText.stories.tsx +136 -0
  13. package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
  14. package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
  15. package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
  16. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
  17. package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
  18. package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
  19. package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
  20. package/src/_stories/_mock/alt_text_metadata.json +38 -0
  21. package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
  22. package/src/_stories/_mock/heatmap.json +156 -0
  23. package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
  24. package/src/_stories/_mock/sankey_mock.json +78 -0
  25. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
  26. package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
  27. package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
  28. package/src/components/Axis/LeftAxis.tsx +14 -11
  29. package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
  30. package/src/components/Brush/BrushSelector.tsx +88 -187
  31. package/src/components/Brush/brushInteraction.ts +22 -0
  32. package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
  33. package/src/components/EditorPanel/EditorPanel.tsx +310 -49
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
  35. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
  36. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
  37. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
  38. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  39. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
  40. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
  41. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
  42. package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
  43. package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
  44. package/src/components/HeatMap/components/HeatMap.tsx +762 -0
  45. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
  46. package/src/components/HeatMap/heatmap.constants.ts +13 -0
  47. package/src/components/HeatMap/heatmap.css +71 -0
  48. package/src/components/HeatMap/helpers/index.ts +261 -0
  49. package/src/components/HeatMap/index.tsx +5 -0
  50. package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
  51. package/src/components/HeatMap/tests/helpers.test.ts +167 -0
  52. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
  53. package/src/components/LineChart/helpers.test.ts +345 -0
  54. package/src/components/LineChart/helpers.ts +15 -5
  55. package/src/components/LineChart/index.tsx +7 -1
  56. package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
  57. package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
  58. package/src/components/LinearChart.tsx +58 -37
  59. package/src/components/PieChart/PieChart.tsx +4 -1
  60. package/src/components/RadarChart/RadarChart.tsx +3 -1
  61. package/src/components/Regions/components/Regions.tsx +195 -41
  62. package/src/components/Sankey/components/Sankey.tsx +7 -3
  63. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
  64. package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
  65. package/src/components/SmallMultiples/SmallMultiples.css +12 -0
  66. package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
  67. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
  68. package/src/components/Sparkline/components/SparkLine.tsx +4 -9
  69. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
  70. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
  71. package/src/data/initial-state.js +11 -0
  72. package/src/helpers/axisLabelFontSize.ts +8 -0
  73. package/src/helpers/bumpChartTooltip.ts +54 -0
  74. package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
  75. package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
  76. package/src/helpers/getMinMax.ts +2 -3
  77. package/src/helpers/getYAxisAutoPadding.ts +8 -6
  78. package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
  79. package/src/helpers/handleChartAriaLabels.ts +23 -10
  80. package/src/helpers/seriesHighlight.ts +26 -0
  81. package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
  82. package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
  83. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
  84. package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
  85. package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
  86. package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
  87. package/src/helpers/tests/seriesHighlight.test.ts +61 -0
  88. package/src/hooks/tests/useScales.test.tsx +105 -0
  89. package/src/hooks/useScales.ts +19 -9
  90. package/src/index.jsx +5 -2
  91. package/src/scss/main.scss +32 -49
  92. package/src/types/ChartConfig.ts +18 -0
  93. 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 BrushSliderInEditor: Story = {
40
+ export const BrushDynamicYAxis: Story = {
40
41
  args: {
41
- config: brushEnabledConfig,
42
- isEditor: true
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
- 'Brush slider in editor mode. You can toggle the "Brush Slider" checkbox in the X-Axis section to enable/disable it.'
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
+ }