@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,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 {
|
|
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
|
|
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
|
|
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={
|
|
65
|
-
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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(
|
|
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 =
|