@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,167 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildHeatMapData,
4
+ getHeatMapBucketPalette,
5
+ getHeatMapColorBucketCount,
6
+ getHeatMapColorScale,
7
+ getHeatMapDataGroupRanges
8
+ } from '../helpers'
9
+ import { type ChartConfig } from '../../../types/ChartConfig'
10
+
11
+ describe('buildHeatMapData', () => {
12
+ it('aggregates duplicate x/series cells using numeric sum', () => {
13
+ const { columns, minValue, maxValue } = buildHeatMapData({
14
+ data: [
15
+ { month: 'Jan', Austin: 2, Boston: 4 },
16
+ { month: 'Jan', Austin: 3, Boston: 0 }
17
+ ],
18
+ xDataKey: 'month',
19
+ series: [
20
+ { dataKey: 'Austin', name: 'Austin' },
21
+ { dataKey: 'Boston', name: 'Boston' }
22
+ ],
23
+ xAxisType: 'categorical'
24
+ })
25
+
26
+ expect(columns).toHaveLength(1)
27
+ expect(columns[0].bins[0].value).toBe(5)
28
+ expect(columns[0].bins[0].sourceRows).toHaveLength(2)
29
+ expect(columns[0].bins[1].value).toBe(4)
30
+ expect(minValue).toBe(4)
31
+ expect(maxValue).toBe(5)
32
+ })
33
+
34
+ it('orders date-based x values in ascending order', () => {
35
+ const { columns } = buildHeatMapData({
36
+ data: [
37
+ { date: '2024-03-01', Austin: 1 },
38
+ { date: '2024-01-01', Austin: 2 },
39
+ { date: '2024-02-01', Austin: 3 }
40
+ ],
41
+ xDataKey: 'date',
42
+ series: [{ dataKey: 'Austin', name: 'Austin' }],
43
+ xAxisType: 'date'
44
+ })
45
+
46
+ expect(columns.map(column => column.key)).toEqual(['2024-01-01', '2024-02-01', '2024-03-01'])
47
+ })
48
+
49
+ it('creates empty cells for missing x/series combinations', () => {
50
+ const { columns, rowLabels } = buildHeatMapData({
51
+ data: [
52
+ { month: 'Jan', Austin: 5 },
53
+ { month: 'Feb', Boston: 4 }
54
+ ],
55
+ xDataKey: 'month',
56
+ series: [
57
+ { dataKey: 'Austin', name: 'Austin' },
58
+ { dataKey: 'Boston', name: 'Boston' }
59
+ ],
60
+ xAxisType: 'categorical'
61
+ })
62
+
63
+ expect(rowLabels).toEqual(['Austin', 'Boston'])
64
+ expect(columns).toHaveLength(2)
65
+ expect(columns[0].bins[1].value).toBeNull()
66
+ expect(columns[1].bins[0].value).toBeNull()
67
+ })
68
+
69
+ it('builds series-driven rows when data series are provided', () => {
70
+ const { columns, rowLabels, minValue, maxValue } = buildHeatMapData({
71
+ data: [
72
+ { month: 'Jan', Austin: 12, Boston: 30 },
73
+ { month: 'Feb', Austin: 18, Boston: 40 }
74
+ ],
75
+ xDataKey: 'month',
76
+ series: [
77
+ { dataKey: 'Austin', name: 'Austin' },
78
+ { dataKey: 'Boston', name: 'Boston' }
79
+ ],
80
+ seriesLabels: { Austin: 'Austin', Boston: 'Boston' },
81
+ xAxisType: 'categorical'
82
+ })
83
+
84
+ expect(rowLabels).toEqual(['Austin', 'Boston'])
85
+ expect(columns).toHaveLength(2)
86
+ expect(columns[0].bins[0].value).toBe(12)
87
+ expect(columns[0].bins[1].value).toBe(30)
88
+ expect(columns[1].bins[0].value).toBe(18)
89
+ expect(columns[1].bins[1].value).toBe(40)
90
+ expect(minValue).toBe(12)
91
+ expect(maxValue).toBe(40)
92
+ })
93
+
94
+ it('keeps non-calendar categories in source order for average age by city', () => {
95
+ const { columns, rowLabels, minValue, maxValue } = buildHeatMapData({
96
+ data: [
97
+ { communityType: 'Urban Core', Atlanta: 34, Chicago: 36, Phoenix: 38 },
98
+ { communityType: 'Suburban', Atlanta: 41, Chicago: 39, Phoenix: 42 },
99
+ { communityType: 'Rural', Atlanta: 46, Chicago: 44, Phoenix: 49 }
100
+ ],
101
+ xDataKey: 'communityType',
102
+ series: [
103
+ { dataKey: 'Atlanta', name: 'Atlanta' },
104
+ { dataKey: 'Chicago', name: 'Chicago' },
105
+ { dataKey: 'Phoenix', name: 'Phoenix' }
106
+ ],
107
+ xAxisType: 'categorical'
108
+ })
109
+
110
+ expect(columns.map(column => column.key)).toEqual(['Urban Core', 'Suburban', 'Rural'])
111
+ expect(rowLabels).toEqual(['Atlanta', 'Chicago', 'Phoenix'])
112
+ expect(columns[0].bins.map(bin => bin.value)).toEqual([34, 36, 38])
113
+ expect(columns[2].bins[2].value).toBe(49)
114
+ expect(minValue).toBe(34)
115
+ expect(maxValue).toBe(49)
116
+ })
117
+ })
118
+
119
+ describe('HeatMap data grouping', () => {
120
+ const buildPaletteConfig = (colorBucketCount?: unknown) =>
121
+ ({
122
+ general: {
123
+ palette: {
124
+ customColors: ['#000000', '#ffffff']
125
+ }
126
+ },
127
+ heatmap: {
128
+ colorBucketCount
129
+ }
130
+ } as ChartConfig)
131
+
132
+ it('clamps HeatMap data grouping to the supported range', () => {
133
+ expect(getHeatMapColorBucketCount(buildPaletteConfig(undefined))).toBe(9)
134
+ expect(getHeatMapColorBucketCount(buildPaletteConfig(0))).toBe(1)
135
+ expect(getHeatMapColorBucketCount(buildPaletteConfig(-4))).toBe(1)
136
+ expect(getHeatMapColorBucketCount(buildPaletteConfig(12))).toBe(9)
137
+ })
138
+
139
+ it('supports one bucket as the minimum heatmap color count', () => {
140
+ const colorScale = getHeatMapColorScale(buildPaletteConfig(1), 0, 100)
141
+
142
+ expect(getHeatMapBucketPalette(buildPaletteConfig(1))).toHaveLength(1)
143
+ expect(colorScale(1)).toBe(colorScale(99))
144
+ })
145
+
146
+ it('uses the configured number of data groups for heatmap cell colors', () => {
147
+ const colorScale = getHeatMapColorScale(buildPaletteConfig(5), 0, 100)
148
+ const bucketColors = [0, 20, 40, 60, 80].map(value => colorScale(value))
149
+
150
+ expect(getHeatMapColorBucketCount(buildPaletteConfig(5))).toBe(5)
151
+ expect(getHeatMapBucketPalette(buildPaletteConfig(5))).toHaveLength(5)
152
+ expect(new Set(bucketColors)).toHaveLength(5)
153
+ expect(colorScale(1)).toBe(colorScale(19))
154
+ })
155
+
156
+ it('builds data group legend ranges from the heatmap grouping domain', () => {
157
+ const ranges = getHeatMapDataGroupRanges(buildPaletteConfig(5), 0, 1, value => value.toFixed(1))
158
+
159
+ expect(ranges.map(range => range.label)).toEqual([
160
+ '0.0\u20130.2',
161
+ '0.2\u20130.4',
162
+ '0.4\u20130.6',
163
+ '0.6\u20130.8',
164
+ '0.8\u20131.0'
165
+ ])
166
+ })
167
+ })
@@ -1,9 +1,11 @@
1
- import { useState } from 'react'
1
+ import { useContext } from 'react'
2
2
  import { Group } from '@visx/group'
3
- import { type Column } from '@cdc/core/types/Column'
4
3
  import React from 'react'
5
4
  import { type ChartConfig } from '../../../types/ChartConfig'
6
5
  import { APP_FONT_COLOR } from '@cdc/core/helpers/constants'
6
+ import ConfigContext from '../../../ConfigContext'
7
+ import { isDateScale } from '@cdc/core/helpers/cove/date'
8
+ import { buildBumpChartTooltipHtml } from '../../../helpers/bumpChartTooltip'
7
9
 
8
10
  type LineChartBumpCircleProp = {
9
11
  config: ChartConfig
@@ -15,17 +17,15 @@ type LineChartBumpCircleProp = {
15
17
 
16
18
  const LineChartBumpCircle = (props: LineChartBumpCircleProp) => {
17
19
  const { config, xScale, yScale, parseDate, yAxisWidth } = props
20
+ const { colorScale, formatDate, formatTooltipsDate } = useContext(ConfigContext)
18
21
 
19
22
  // get xScale and yScale...
20
23
  if (!config?.runtime?.series) return
21
24
 
22
25
  const handleX = xValue => {
23
- if (config.xAxis.type === 'date') {
26
+ if (isDateScale(config.xAxis)) {
24
27
  return parseDate(xValue).getTime()
25
28
  }
26
- if (config.xAxis.type === 'date-time') {
27
- return new Date(xValue)
28
- }
29
29
  if (config.xAxis.type === 'categorical') {
30
30
  return xValue
31
31
  }
@@ -35,20 +35,7 @@ const LineChartBumpCircle = (props: LineChartBumpCircleProp) => {
35
35
  return xScale.bandwidth ? xScale.bandwidth() / 2 + Number(xValue) : Number(xValue)
36
36
  }
37
37
 
38
- const getListItems = dataRow => {
39
- return Object.values(config.columns)
40
- ?.filter(column => column.tooltips)
41
- .map(column => {
42
- const label = column.label || column.name
43
- return `
44
- <li className='tooltip-body'>
45
- <strong>${label}</strong>: ${dataRow[column.name]}
46
- </li>`
47
- })
48
- .join(' ')
49
- }
50
-
51
- const getTooltip = dataRow => `<ul> ${getListItems(dataRow)} </ul>`
38
+ const tooltipId = `cdc-open-viz-tooltip-${config.runtime.uniqueId}-bump`
52
39
 
53
40
  const circles = config.runtime?.series.map(series => {
54
41
  return config.data.map((d, dataIndex) => {
@@ -61,8 +48,18 @@ const LineChartBumpCircle = (props: LineChartBumpCircleProp) => {
61
48
  <>
62
49
  <circle
63
50
  key={`bump-circle-${series_dataKey}-${dataIndex}`}
64
- data-tooltip-html={getTooltip(d)}
65
- data-tooltip-id={`bump-chart`}
51
+ data-tooltip-html={buildBumpChartTooltipHtml({
52
+ config,
53
+ colorScale,
54
+ dataRow: d,
55
+ series,
56
+ helpers: {
57
+ formatDate,
58
+ formatTooltipsDate,
59
+ parseDate
60
+ }
61
+ })}
62
+ data-tooltip-id={tooltipId}
66
63
  r={10}
67
64
  cx={Number(checkBandScale(xScale(handleX(axis_dataKey))))}
68
65
  cy={Number(yScale(series_dataKey))}
@@ -0,0 +1,345 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createStyles, filterCircles } from './helpers'
3
+ import { PreliminaryDataItem } from '../../types/ChartConfig'
4
+
5
+ describe('LineChart helpers', () => {
6
+ describe('createStyles', () => {
7
+ const mockHandleLineType = vi.fn((style: string) => {
8
+ switch (style) {
9
+ case 'Dashed Small':
10
+ return '5 5'
11
+ case 'Dashed Medium':
12
+ return '10 5'
13
+ case 'Dashed Large':
14
+ return '15 5'
15
+ default:
16
+ return 0
17
+ }
18
+ })
19
+
20
+ const basePreliminaryData: PreliminaryDataItem[] = [
21
+ {
22
+ type: 'effect',
23
+ seriesKeys: ['COVID-19'],
24
+ column: 'Attribute',
25
+ value: 'Dotted',
26
+ style: 'Dashed Small',
27
+ label: 'COVID Dotted',
28
+ displayTooltip: true,
29
+ displayLegend: true,
30
+ displayTable: true,
31
+ symbol: '',
32
+ iconCode: '',
33
+ lineCode: '',
34
+ hideBarSymbol: false,
35
+ hideLineStyle: false,
36
+ circleSize: 6,
37
+ displayGray: false
38
+ },
39
+ {
40
+ type: 'effect',
41
+ seriesKeys: ['Influenza'],
42
+ column: 'Attribute',
43
+ value: 'Dotted',
44
+ style: 'Dashed Medium',
45
+ label: 'Flu Dotted',
46
+ displayTooltip: true,
47
+ displayLegend: true,
48
+ displayTable: true,
49
+ symbol: '',
50
+ iconCode: '',
51
+ lineCode: '',
52
+ hideBarSymbol: false,
53
+ hideLineStyle: false,
54
+ circleSize: 6,
55
+ displayGray: false
56
+ }
57
+ ]
58
+
59
+ describe('with dynamic category data (single-row/tall format)', () => {
60
+ const dynamicCategoryData = [
61
+ { Date: '10/5/2025', Category: 'COVID-19', Value: '43.6', Attribute: 'Dotted' },
62
+ { Date: '10/12/2025', Category: 'COVID-19', Value: '40.7', Attribute: '' },
63
+ { Date: '10/19/2025', Category: 'COVID-19', Value: '42.6', Attribute: 'Dotted' }
64
+ ]
65
+
66
+ it('should apply dashed style when Attribute matches effect value', () => {
67
+ const styles = createStyles({
68
+ preliminaryData: basePreliminaryData,
69
+ data: dynamicCategoryData,
70
+ stroke: '#000',
71
+ strokeWidth: 2,
72
+ handleLineType: mockHandleLineType,
73
+ lineType: 'solid-line',
74
+ seriesKey: 'COVID-19', // Runtime series key from Category column
75
+ dynamicCategory: 'Category',
76
+ originalSeriesKey: 'Value' // Data value column
77
+ })
78
+
79
+ expect(styles).toHaveLength(3)
80
+ // First point has Attribute="Dotted" - should be dashed
81
+ expect(styles[0].strokeDasharray).toBe('5 5')
82
+ // Second point has Attribute="" - but previous point was dashed, so it inherits
83
+ // Actually, the logic applies dashed to current AND previous point when effect is found
84
+ // Let's check what happens
85
+ expect(styles[1].strokeDasharray).toBe('5 5') // Inherits from next point's effect
86
+ // Third point has Attribute="Dotted" - should be dashed
87
+ expect(styles[2].strokeDasharray).toBe('5 5')
88
+ })
89
+
90
+ it('should apply solid style when no effect matches', () => {
91
+ const dataNoEffects = [
92
+ { Date: '10/5/2025', Category: 'COVID-19', Value: '43.6', Attribute: '' },
93
+ { Date: '10/12/2025', Category: 'COVID-19', Value: '40.7', Attribute: '' }
94
+ ]
95
+
96
+ const styles = createStyles({
97
+ preliminaryData: basePreliminaryData,
98
+ data: dataNoEffects,
99
+ stroke: '#000',
100
+ strokeWidth: 2,
101
+ handleLineType: mockHandleLineType,
102
+ lineType: 'solid-line',
103
+ seriesKey: 'COVID-19',
104
+ dynamicCategory: 'Category',
105
+ originalSeriesKey: 'Value'
106
+ })
107
+
108
+ expect(styles).toHaveLength(2)
109
+ expect(styles[0].strokeDasharray).toBe(0)
110
+ expect(styles[1].strokeDasharray).toBe(0)
111
+ })
112
+
113
+ it('should only match effects for the correct series', () => {
114
+ // Using Influenza series key with COVID data should not match
115
+ const styles = createStyles({
116
+ preliminaryData: basePreliminaryData,
117
+ data: dynamicCategoryData,
118
+ stroke: '#000',
119
+ strokeWidth: 2,
120
+ handleLineType: mockHandleLineType,
121
+ lineType: 'solid-line',
122
+ seriesKey: 'Influenza', // Different series
123
+ dynamicCategory: 'Category',
124
+ originalSeriesKey: 'Value'
125
+ })
126
+
127
+ // No effect should match since seriesKeys is ['COVID-19'] not ['Influenza']
128
+ // (assuming we use the COVID effect config)
129
+ expect(styles.every(s => s.strokeDasharray === 0 || s.strokeDasharray === '10 5')).toBe(true)
130
+ })
131
+ })
132
+
133
+ describe('with multi-column data (standard format)', () => {
134
+ const multiColumnPreliminaryData: PreliminaryDataItem[] = [
135
+ {
136
+ type: 'effect',
137
+ seriesKeys: ['COVID-19'],
138
+ column: 'COVID-19-Attribute',
139
+ value: 'Dotted',
140
+ style: 'Dashed Small',
141
+ label: 'COVID Dotted',
142
+ displayTooltip: true,
143
+ displayLegend: true,
144
+ displayTable: true,
145
+ symbol: '',
146
+ iconCode: '',
147
+ lineCode: '',
148
+ hideBarSymbol: false,
149
+ hideLineStyle: false,
150
+ circleSize: 6,
151
+ displayGray: false
152
+ }
153
+ ]
154
+
155
+ const multiColumnData = [
156
+ { Date: '10/5/2025', 'COVID-19': '43.6', 'COVID-19-Attribute': 'Dotted' },
157
+ { Date: '10/12/2025', 'COVID-19': '40.7', 'COVID-19-Attribute': '' },
158
+ { Date: '10/19/2025', 'COVID-19': '42.6', 'COVID-19-Attribute': 'Dotted' }
159
+ ]
160
+
161
+ it('should apply dashed style when attribute column matches', () => {
162
+ const styles = createStyles({
163
+ preliminaryData: multiColumnPreliminaryData,
164
+ data: multiColumnData,
165
+ stroke: '#000',
166
+ strokeWidth: 2,
167
+ handleLineType: mockHandleLineType,
168
+ lineType: 'solid-line',
169
+ seriesKey: 'COVID-19',
170
+ dynamicCategory: '', // No dynamic category
171
+ originalSeriesKey: ''
172
+ })
173
+
174
+ expect(styles).toHaveLength(3)
175
+ // First point has attribute - dashed
176
+ expect(styles[0].strokeDasharray).toBe('5 5')
177
+ // Second point inherits from third point's effect
178
+ expect(styles[1].strokeDasharray).toBe('5 5')
179
+ // Third point has attribute - dashed
180
+ expect(styles[2].strokeDasharray).toBe('5 5')
181
+ })
182
+ })
183
+ })
184
+
185
+ describe('filterCircles', () => {
186
+ const circlesPreliminaryData: PreliminaryDataItem[] = [
187
+ {
188
+ type: 'effect',
189
+ seriesKeys: ['COVID-19'],
190
+ column: 'Attribute',
191
+ value: 'Marked',
192
+ style: 'Open Circles',
193
+ label: 'COVID Marked',
194
+ displayTooltip: true,
195
+ displayLegend: true,
196
+ displayTable: true,
197
+ symbol: '',
198
+ iconCode: '',
199
+ lineCode: '',
200
+ hideBarSymbol: false,
201
+ hideLineStyle: false,
202
+ circleSize: 8,
203
+ displayGray: false
204
+ },
205
+ {
206
+ type: 'effect',
207
+ seriesKeys: ['Influenza'],
208
+ column: 'Attribute',
209
+ value: 'Marked',
210
+ style: 'Filled Circles',
211
+ label: 'Flu Marked',
212
+ displayTooltip: true,
213
+ displayLegend: true,
214
+ displayTable: true,
215
+ symbol: '',
216
+ iconCode: '',
217
+ lineCode: '',
218
+ hideBarSymbol: false,
219
+ hideLineStyle: false,
220
+ circleSize: 6,
221
+ displayGray: false
222
+ }
223
+ ]
224
+
225
+ describe('with dynamic category data', () => {
226
+ const dynamicData = [
227
+ { Date: '10/5/2025', Category: 'COVID-19', Value: '43.6', Attribute: 'Marked' },
228
+ { Date: '10/12/2025', Category: 'COVID-19', Value: '40.7', Attribute: '' },
229
+ { Date: '10/19/2025', Category: 'COVID-19', Value: '42.6', Attribute: 'Marked' }
230
+ ]
231
+
232
+ it('should find open circles for matching dynamic category data', () => {
233
+ const circles = filterCircles(
234
+ circlesPreliminaryData,
235
+ dynamicData,
236
+ 'COVID-19', // Runtime series key
237
+ 'Category', // Dynamic category column
238
+ 'Value' // Value column for data existence check
239
+ )
240
+
241
+ expect(circles).toHaveLength(2) // Two data points with Attribute='Marked'
242
+ expect(circles[0].isFilled).toBe(false) // Open circles
243
+ expect(circles[0].size).toBe(8)
244
+ expect(circles[1].isFilled).toBe(false)
245
+ })
246
+
247
+ it('should return empty array when no matches', () => {
248
+ const circles = filterCircles(
249
+ circlesPreliminaryData,
250
+ dynamicData,
251
+ 'RSV', // Series not in preliminaryData
252
+ 'Category',
253
+ 'Value'
254
+ )
255
+
256
+ expect(circles).toHaveLength(0)
257
+ })
258
+ })
259
+
260
+ describe('with multi-column data (standard format)', () => {
261
+ const multiColumnCirclesConfig: PreliminaryDataItem[] = [
262
+ {
263
+ type: 'effect',
264
+ seriesKeys: ['COVID-19'],
265
+ column: 'COVID-19-Attribute',
266
+ value: 'Marked',
267
+ style: 'Open Circles',
268
+ label: 'COVID Marked',
269
+ displayTooltip: true,
270
+ displayLegend: true,
271
+ displayTable: true,
272
+ symbol: '',
273
+ iconCode: '',
274
+ lineCode: '',
275
+ hideBarSymbol: false,
276
+ hideLineStyle: false,
277
+ circleSize: 8,
278
+ displayGray: false
279
+ }
280
+ ]
281
+
282
+ const multiColumnData = [
283
+ { Date: '10/5/2025', 'COVID-19': '43.6', 'COVID-19-Attribute': 'Marked' },
284
+ { Date: '10/12/2025', 'COVID-19': '40.7', 'COVID-19-Attribute': '' },
285
+ { Date: '10/19/2025', 'COVID-19': '42.6', 'COVID-19-Attribute': 'Marked' }
286
+ ]
287
+
288
+ it('should find circles in standard multi-column format', () => {
289
+ const circles = filterCircles(
290
+ multiColumnCirclesConfig,
291
+ multiColumnData,
292
+ 'COVID-19'
293
+ // No dynamicCategory or originalSeriesKey - standard mode
294
+ )
295
+
296
+ expect(circles).toHaveLength(2)
297
+ expect(circles[0].isFilled).toBe(false)
298
+ expect(circles[0].size).toBe(8)
299
+ })
300
+
301
+ it('should be backwards compatible with 3-argument call', () => {
302
+ // Existing code that doesn't pass dynamicCategory should still work
303
+ const circles = filterCircles(multiColumnCirclesConfig, multiColumnData, 'COVID-19')
304
+
305
+ expect(circles).toHaveLength(2)
306
+ })
307
+ })
308
+
309
+ describe('filled circles', () => {
310
+ const filledCirclesConfig: PreliminaryDataItem[] = [
311
+ {
312
+ type: 'effect',
313
+ seriesKeys: ['Influenza'],
314
+ column: 'Attribute',
315
+ value: 'Marked',
316
+ style: 'Filled Circles',
317
+ label: 'Flu Marked',
318
+ displayTooltip: true,
319
+ displayLegend: true,
320
+ displayTable: true,
321
+ symbol: '',
322
+ iconCode: '',
323
+ lineCode: '',
324
+ hideBarSymbol: false,
325
+ hideLineStyle: false,
326
+ circleSize: 6,
327
+ displayGray: false
328
+ }
329
+ ]
330
+
331
+ it('should find filled circles for dynamic category data', () => {
332
+ const data = [
333
+ { Date: '10/5/2025', Category: 'Influenza', Value: '23.1', Attribute: 'Marked' },
334
+ { Date: '10/12/2025', Category: 'Influenza', Value: '31.7', Attribute: '' }
335
+ ]
336
+
337
+ const circles = filterCircles(filledCirclesConfig, data, 'Influenza', 'Category', 'Value')
338
+
339
+ expect(circles).toHaveLength(1)
340
+ expect(circles[0].isFilled).toBe(true)
341
+ expect(circles[0].size).toBe(6)
342
+ })
343
+ })
344
+ })
345
+ })
@@ -15,17 +15,18 @@ export const createStyles = (props: StyleProps): Style[] => {
15
15
  originalSeriesKey
16
16
  } = props
17
17
 
18
- const dynamicSeriesKey = dynamicCategory ? originalSeriesKey : seriesKey
19
18
  const validPreliminaryData: PreliminaryDataItem[] = preliminaryData.filter(
20
19
  pd => pd.seriesKeys?.length && pd.column && pd.value && pd.type && pd.style && pd.type === 'effect'
21
20
  )
22
21
  const isEffectLine = (pd, dataPoint) => {
23
22
  if (dynamicCategory) {
23
+ // In dynamic category mode, check the attribute column (pd.column) for the effect marker
24
+ // seriesKey is the runtime series key derived from the category column
24
25
  return (
25
26
  pd.type === 'effect' &&
26
27
  pd.style !== 'Open Circles' &&
27
28
  pd.seriesKeys.includes(seriesKey) &&
28
- String(dataPoint[dynamicSeriesKey]) === String(pd.value)
29
+ dataPoint[pd.column] === pd.value
29
30
  )
30
31
  } else {
31
32
  return (
@@ -67,8 +68,17 @@ export const createStyles = (props: StyleProps): Style[] => {
67
68
  export const filterCircles = (
68
69
  preliminaryData: PreliminaryDataItem[],
69
70
  data: DataItem[],
70
- seriesKey: string
71
+ seriesKey: string,
72
+ dynamicCategory?: string,
73
+ originalSeriesKey?: string
71
74
  ): DataItem[] => {
75
+ // In dynamic category mode:
76
+ // - seriesKey is the runtime series key for matching against seriesKeys
77
+ // - originalSeriesKey is the data value column (e.g., "Value") for checking if data exists
78
+ // In standard mode:
79
+ // - seriesKey is used for both purposes
80
+ const valueKey = dynamicCategory ? originalSeriesKey : seriesKey
81
+
72
82
  // Filter and map preliminaryData to get circlesFiltered
73
83
  const circlesFiltered = preliminaryData
74
84
  ?.filter(item => item.style.includes('Circles') && item.type === 'effect' && item.seriesKeys?.length)
@@ -86,7 +96,7 @@ export const filterCircles = (
86
96
  if (
87
97
  item[fc.column] === fc.value &&
88
98
  fc.seriesKeys.includes(seriesKey) &&
89
- item[seriesKey] &&
99
+ item[valueKey] &&
90
100
  fc.style === 'Open Circles'
91
101
  ) {
92
102
  const result = {
@@ -99,7 +109,7 @@ export const filterCircles = (
99
109
  if (
100
110
  (!fc.value || item[fc.column] === fc.value) &&
101
111
  fc.seriesKeys.includes(seriesKey) &&
102
- item[seriesKey] &&
112
+ item[valueKey] &&
103
113
  fc.style === 'Filled Circles'
104
114
  ) {
105
115
  const result = {
@@ -80,7 +80,13 @@ const LineChart = (props: LineChartProps) => {
80
80
  const _data = seriesData.dynamicCategory
81
81
  ? data.filter(d => d[seriesData.dynamicCategory] === seriesKey)
82
82
  : data
83
- const circleData = filterCircles(config?.preliminaryData, tableData, _seriesKey)
83
+ const circleData = filterCircles(
84
+ config?.preliminaryData,
85
+ tableData,
86
+ seriesKey,
87
+ seriesData.dynamicCategory,
88
+ _seriesKey
89
+ )
84
90
 
85
91
  // Prepare sorted data for line and area rendering
86
92
  const sortedData =