@cdc/chart 4.26.5 → 4.26.7
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 +20 -14
- package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
- package/dist/cdcchart.js +36554 -34320
- package/examples/feature/heatmap/average-age-categorical.json +1 -1
- package/examples/feature/heatmap/calendar-heatmap.json +1 -1
- package/examples/feature/heatmap/sparse-aggregation.json +1 -1
- package/examples/private/bad-y-axis.json +543 -0
- package/examples/private/bar-chart-negative-value.json +455 -0
- package/examples/private/flueview.json +22051 -0
- package/package.json +3 -11
- package/src/CdcChart.tsx +7 -6
- package/src/CdcChartComponent.tsx +71 -18
- package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
- package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
- package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
- package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
- package/src/_stories/Chart.smoke.stories.tsx +14 -1
- package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
- package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
- package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
- package/src/_stories/_mock/heatmap.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
- package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
- package/src/components/Axis/LeftAxis.tsx +3 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
- package/src/components/Axis/RightAxis.tsx +3 -0
- package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
- package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
- package/src/components/BarChart/helpers/getBarData.ts +28 -28
- package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
- package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
- package/src/components/BarChart/helpers/useBarChart.ts +4 -5
- package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
- package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
- package/src/components/BoxPlot/helpers/index.ts +4 -3
- package/src/components/Brush/BrushSelector.tsx +1 -1
- package/src/components/EditorPanel/EditorPanel.tsx +457 -165
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
- package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
- package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
- package/src/components/EditorPanel/components/panels.scss +10 -0
- package/src/components/EditorPanel/editor-panel.scss +47 -0
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
- package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +288 -11
- package/src/components/EditorPanel/useEditorPermissions.ts +46 -6
- package/src/components/Forecasting/Forecasting.tsx +1 -1
- package/src/components/HeatMap/components/HeatMap.tsx +22 -17
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
- package/src/components/HeatMap/heatmap.css +15 -1
- package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
- package/src/components/Legend/Legend.Component.tsx +56 -61
- package/src/components/Legend/Legend.Suppression.tsx +3 -3
- package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
- package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
- package/src/components/LineChart/helpers.test.ts +230 -1
- package/src/components/LineChart/helpers.ts +25 -14
- package/src/components/LineChart/index.tsx +23 -7
- package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
- package/src/components/LinearChart.tsx +51 -15
- package/src/components/PieChart/PieChart.tsx +117 -10
- package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
- package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
- package/src/components/ScatterPlot/ScatterPlot.jsx +5 -13
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
- package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
- package/src/data/initial-state.js +3 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
- package/src/helpers/categoryOrder.ts +60 -0
- package/src/helpers/colorAssignmentHelpers.ts +102 -0
- package/src/helpers/dataHelpers.ts +11 -1
- package/src/helpers/getAxisMaxOverride.ts +16 -0
- package/src/helpers/getBoxPlotConfig.ts +19 -12
- package/src/helpers/getCleanTopTickMax.ts +64 -0
- package/src/helpers/getColorScale.ts +18 -9
- package/src/helpers/getComboChartConfig.ts +0 -1
- package/src/helpers/getExcludedData.ts +0 -1
- package/src/helpers/getHasBoundarySuppression.ts +27 -0
- package/src/helpers/getMinMax.ts +25 -22
- package/src/helpers/getNewRuntime.ts +0 -2
- package/src/helpers/getPiePercent.ts +3 -3
- package/src/helpers/getYAxisDomainData.ts +38 -0
- package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
- package/src/helpers/hasSpacedInlineLabel.ts +10 -0
- package/src/helpers/isConvertLineToBarGraph.ts +2 -2
- package/src/helpers/seriesColumnSettings.ts +26 -7
- package/src/helpers/sizeHelpers.ts +0 -2
- package/src/helpers/smallMultiplesHelpers.ts +5 -0
- package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
- package/src/helpers/tests/categoryOrder.test.ts +57 -0
- package/src/helpers/tests/dataHelpers.test.ts +135 -0
- package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
- package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
- package/src/helpers/tests/getColorScale.test.ts +197 -0
- package/src/helpers/tests/getMinMax.test.ts +211 -0
- package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
- package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
- package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
- package/src/helpers/tests/seriesColumnSettings.test.ts +76 -2
- package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
- package/src/hooks/tests/useRightAxis.test.tsx +268 -0
- package/src/hooks/tests/useScales.test.tsx +302 -27
- package/src/hooks/tests/useTooltip.test.tsx +132 -0
- package/src/hooks/useRightAxis.ts +35 -25
- package/src/hooks/useScales.ts +45 -28
- package/src/hooks/useTooltip.tsx +17 -11
- package/src/store/chart.reducer.ts +0 -1
- package/src/test/CdcChart.dataTable.test.tsx +216 -0
- package/src/types/ChartConfig.ts +6 -1
- package/src/types/ChartContext.ts +3 -0
- package/src/helpers/getYAxisAutoPadding.ts +0 -55
- package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +0 -37
|
@@ -116,7 +116,7 @@ const buildHeatMapContext = () => {
|
|
|
116
116
|
hide: false,
|
|
117
117
|
position: 'bottom',
|
|
118
118
|
style: 'gradient',
|
|
119
|
-
subStyle: '
|
|
119
|
+
subStyle: 'linear blocks',
|
|
120
120
|
hideBorder: {
|
|
121
121
|
side: false,
|
|
122
122
|
topBottom: true
|
|
@@ -264,7 +264,7 @@ const buildSeriesModeHeatMapContext = () => {
|
|
|
264
264
|
hide: false,
|
|
265
265
|
position: 'top',
|
|
266
266
|
style: 'gradient',
|
|
267
|
-
subStyle: '
|
|
267
|
+
subStyle: 'linear blocks',
|
|
268
268
|
hideBorder: {
|
|
269
269
|
side: false,
|
|
270
270
|
topBottom: true
|
|
@@ -453,6 +453,28 @@ const getHeatMapPlotTop = (container: HTMLElement) => getTranslateY(container.qu
|
|
|
453
453
|
describe('HeatMap', () => {
|
|
454
454
|
it('renders cells with tooltip metadata from additional columns', () => {
|
|
455
455
|
const context = buildHeatMapContext()
|
|
456
|
+
const rows = [
|
|
457
|
+
{ month: '2024-02-01', North: 5, South: 7, notes: 'Severe', population: 2500 },
|
|
458
|
+
{ month: '2024-01-01', North: 2, South: 4, notes: 'Low', population: 1000 }
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
context.config.data = rows
|
|
462
|
+
context.config.filteredData = rows
|
|
463
|
+
context.config.excludedData = rows
|
|
464
|
+
context.filteredData = rows
|
|
465
|
+
context.excludedData = rows
|
|
466
|
+
context.rawData = rows
|
|
467
|
+
context.tableData = rows
|
|
468
|
+
;(context.config as any).columns.population = {
|
|
469
|
+
label: 'Population',
|
|
470
|
+
tooltips: true,
|
|
471
|
+
dataTable: true,
|
|
472
|
+
prefix: '$',
|
|
473
|
+
suffix: ' residents',
|
|
474
|
+
roundToPlace: 0,
|
|
475
|
+
commas: true
|
|
476
|
+
}
|
|
477
|
+
|
|
456
478
|
const { container } = render(
|
|
457
479
|
<ConfigContext.Provider value={context}>
|
|
458
480
|
<HeatMap parentWidth={800} parentHeight={320} />
|
|
@@ -469,6 +491,7 @@ describe('HeatMap', () => {
|
|
|
469
491
|
expect(tooltipHtml).toContain('Region:')
|
|
470
492
|
expect(tooltipHtml).toContain('Value:')
|
|
471
493
|
expect(tooltipHtml).toContain('Notes')
|
|
494
|
+
expect(tooltipHtml).toContain('Population: $1,000 residents')
|
|
472
495
|
expect(tooltipHtml).not.toContain('<br/>')
|
|
473
496
|
expect(cells[0]?.getAttribute('tabindex')).toBe('0')
|
|
474
497
|
expect(cells[0]?.getAttribute('aria-label')).toContain('Month: Tooltip 2024-01-01')
|
|
@@ -548,16 +571,20 @@ describe('HeatMap', () => {
|
|
|
548
571
|
|
|
549
572
|
it('renders a gradient legend for the mapped value column', () => {
|
|
550
573
|
const context = buildHeatMapContext()
|
|
574
|
+
;(context.config as any).heatmap.colorBucketCount = 5
|
|
551
575
|
|
|
552
|
-
render(
|
|
576
|
+
const { container } = render(
|
|
553
577
|
<ConfigContext.Provider value={context}>
|
|
554
578
|
<HeatMapGradientLegend />
|
|
555
579
|
</ConfigContext.Provider>
|
|
556
580
|
)
|
|
557
581
|
|
|
582
|
+
const rangeLabels = Array.from(container.querySelectorAll('.cdc-heatmap__legend-range-label')).map(label =>
|
|
583
|
+
label.textContent?.trim()
|
|
584
|
+
)
|
|
585
|
+
|
|
558
586
|
expect(screen.getByText('Value')).toBeTruthy()
|
|
559
|
-
expect(
|
|
560
|
-
expect(screen.getByText('7')).toBeTruthy()
|
|
587
|
+
expect(rangeLabels).toEqual(['2\u20133', '3\u20134', '4\u20135', '5\u20136', '6\u20137'])
|
|
561
588
|
})
|
|
562
589
|
|
|
563
590
|
it('renders HeatMap gradient legends as linear blocks', () => {
|
|
@@ -1003,6 +1030,7 @@ describe('HeatMap', () => {
|
|
|
1003
1030
|
const yAxisHeading = screen.getByText('Left Value Axis')
|
|
1004
1031
|
const dateCategoryHeading = screen.getByText('Date/Category Axis')
|
|
1005
1032
|
const settingsHeading = screen.getByText('HeatMap Settings')
|
|
1033
|
+
const legendHeading = screen.getByText('Legend')
|
|
1006
1034
|
expect(yAxisHeading).toBeTruthy()
|
|
1007
1035
|
expect(settingsHeading).toBeTruthy()
|
|
1008
1036
|
expect(
|
|
@@ -1015,12 +1043,14 @@ describe('HeatMap', () => {
|
|
|
1015
1043
|
fireEvent.click(dateCategoryHeading)
|
|
1016
1044
|
fireEvent.click(settingsHeading)
|
|
1017
1045
|
fireEvent.click(dataSeriesHeading)
|
|
1046
|
+
fireEvent.click(legendHeading)
|
|
1018
1047
|
|
|
1019
1048
|
const xAxisPositionLabel = screen.getByText('X-Axis Position')
|
|
1049
|
+
const gradientStyleSelect = screen.getByLabelText('Gradient Style') as HTMLSelectElement
|
|
1020
1050
|
|
|
1021
1051
|
expect(screen.queryByText('Value Column')).toBeNull()
|
|
1022
|
-
expect(screen.getAllByText('Hide Axis')).toHaveLength(
|
|
1023
|
-
expect(screen.getAllByText('Hide Ticks')).toHaveLength(
|
|
1052
|
+
expect(screen.getAllByText('Hide Axis')).toHaveLength(2)
|
|
1053
|
+
expect(screen.getAllByText('Hide Ticks')).toHaveLength(2)
|
|
1024
1054
|
expect(screen.getAllByText('Tick rotation (Degrees)').length).toBeGreaterThan(0)
|
|
1025
1055
|
expect(screen.getByLabelText('Label Placement')).toBeTruthy()
|
|
1026
1056
|
expect(screen.getByText('Add Data Series')).toBeTruthy()
|
|
@@ -1036,5 +1066,7 @@ describe('HeatMap', () => {
|
|
|
1036
1066
|
expect(screen.getByText('Column Label Gap')).toBeTruthy()
|
|
1037
1067
|
expect(screen.getByLabelText(/Data Grouping/i)).toBeTruthy()
|
|
1038
1068
|
expect(screen.getByText('Displaying Rows')).toBeTruthy()
|
|
1069
|
+
expect(Array.from(gradientStyleSelect.options).map(option => option.value)).toEqual(['linear blocks'])
|
|
1070
|
+
expect(screen.queryByLabelText('Tick Rotation (Degrees)')).toBeNull()
|
|
1039
1071
|
})
|
|
1040
1072
|
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import parse from 'html-react-parser'
|
|
2
2
|
import React, { useMemo } from 'react'
|
|
3
3
|
import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend'
|
|
4
|
-
import { PatternLines, PatternCircles, PatternWaves } from '@visx/pattern'
|
|
5
4
|
import LegendShape from '@cdc/core/components/LegendShape'
|
|
6
5
|
import Button from '@cdc/core/components/elements/Button'
|
|
7
6
|
import { getLegendClasses } from './helpers/getLegendClasses'
|
|
@@ -22,6 +21,7 @@ import { getHorizonLayerColors, getHorizonMaxValue } from '../../components/Hori
|
|
|
22
21
|
import { getSeriesWithData } from '../../helpers/dataHelpers'
|
|
23
22
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
24
23
|
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
24
|
+
import { processMarkupVariables } from '@cdc/core/helpers/markupProcessor'
|
|
25
25
|
|
|
26
26
|
const LEGEND_PADDING = 36
|
|
27
27
|
|
|
@@ -62,6 +62,42 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
62
62
|
const { innerClasses, containerClasses } = getLegendClasses(config)
|
|
63
63
|
const { runtime, legend } = config
|
|
64
64
|
const { series } = runtime
|
|
65
|
+
const processedLegendText = useMemo(() => {
|
|
66
|
+
if (!config.enableMarkupVariables || !config.markupVariables?.length) {
|
|
67
|
+
return {
|
|
68
|
+
label: legend?.label,
|
|
69
|
+
description: legend?.description
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const markupOptions = {
|
|
74
|
+
isEditor: false,
|
|
75
|
+
filters: config.filters || [],
|
|
76
|
+
locale: config.locale,
|
|
77
|
+
dataMetadata: config.dataMetadata
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
label: legend?.label
|
|
82
|
+
? processMarkupVariables(legend.label, data || config.data || [], config.markupVariables, markupOptions)
|
|
83
|
+
.processedContent
|
|
84
|
+
: legend?.label,
|
|
85
|
+
description: legend?.description
|
|
86
|
+
? processMarkupVariables(legend.description, data || config.data || [], config.markupVariables, markupOptions)
|
|
87
|
+
.processedContent
|
|
88
|
+
: legend?.description
|
|
89
|
+
}
|
|
90
|
+
}, [
|
|
91
|
+
config.enableMarkupVariables,
|
|
92
|
+
config.markupVariables,
|
|
93
|
+
config.filters,
|
|
94
|
+
config.locale,
|
|
95
|
+
config.dataMetadata,
|
|
96
|
+
config.data,
|
|
97
|
+
data,
|
|
98
|
+
legend?.label,
|
|
99
|
+
legend?.description
|
|
100
|
+
])
|
|
65
101
|
|
|
66
102
|
const seriesWithData = getSeriesWithData(config)
|
|
67
103
|
// For Radar charts, seriesWithData contains dimension keys but legend shows entity names
|
|
@@ -115,10 +151,12 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
115
151
|
aria-label='legend'
|
|
116
152
|
tabIndex={0}
|
|
117
153
|
>
|
|
118
|
-
{(
|
|
119
|
-
<div className={
|
|
120
|
-
{
|
|
121
|
-
{
|
|
154
|
+
{(processedLegendText.label || processedLegendText.description) && (
|
|
155
|
+
<div className={processedLegendText.description ? 'mb-3' : 'mb-2'}>
|
|
156
|
+
{processedLegendText.label && <h3 className='fw-bold cove-prose'>{parse(processedLegendText.label)}</h3>}
|
|
157
|
+
{processedLegendText.description && (
|
|
158
|
+
<p className='mt-2 cove-prose'>{parse(processedLegendText.description)}</p>
|
|
159
|
+
)}
|
|
122
160
|
</div>
|
|
123
161
|
)}
|
|
124
162
|
<LegendGradient
|
|
@@ -242,7 +280,7 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
242
280
|
</>
|
|
243
281
|
)}
|
|
244
282
|
</>
|
|
245
|
-
<LegendLabel align='left' className='m-0
|
|
283
|
+
<LegendLabel align='left' className='m-0'>
|
|
246
284
|
{parse(label.text)}
|
|
247
285
|
</LegendLabel>
|
|
248
286
|
</LegendItem>
|
|
@@ -298,9 +336,8 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
298
336
|
>
|
|
299
337
|
{Object.entries(config.legend.patterns).map(([key, pattern]) => {
|
|
300
338
|
const patternId = `legend-pattern-${key}`
|
|
301
|
-
const size = config.legend.patternSize || 8
|
|
302
|
-
const legendSize = 16
|
|
303
339
|
const pColor = (pattern as any)?.color || '#666666'
|
|
340
|
+
const patternSize = (pattern as any)?.patternSize ?? 10
|
|
304
341
|
|
|
305
342
|
return (
|
|
306
343
|
<LegendItem
|
|
@@ -309,59 +346,17 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
309
346
|
tabIndex={0}
|
|
310
347
|
role='button'
|
|
311
348
|
>
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
)}
|
|
324
|
-
{pattern.shape === 'lines' && (
|
|
325
|
-
<PatternLines
|
|
326
|
-
id={patternId}
|
|
327
|
-
height={size}
|
|
328
|
-
width={size}
|
|
329
|
-
stroke={pColor}
|
|
330
|
-
strokeWidth={0.75}
|
|
331
|
-
orientation={['horizontal']}
|
|
332
|
-
/>
|
|
333
|
-
)}
|
|
334
|
-
{pattern.shape === 'diagonalLines' && (
|
|
335
|
-
<PatternLines
|
|
336
|
-
id={patternId}
|
|
337
|
-
height={size}
|
|
338
|
-
width={size}
|
|
339
|
-
stroke={pColor}
|
|
340
|
-
strokeWidth={0.75}
|
|
341
|
-
orientation={['diagonalRightToLeft']}
|
|
342
|
-
/>
|
|
343
|
-
)}
|
|
344
|
-
{pattern.shape === 'waves' && (
|
|
345
|
-
<PatternWaves
|
|
346
|
-
id={patternId}
|
|
347
|
-
height={size}
|
|
348
|
-
width={size}
|
|
349
|
-
fill={pColor}
|
|
350
|
-
strokeWidth={0.25}
|
|
351
|
-
/>
|
|
352
|
-
)}
|
|
353
|
-
</defs>
|
|
354
|
-
<circle
|
|
355
|
-
fill={`url(#${patternId})`}
|
|
356
|
-
r={legendSize / 2}
|
|
357
|
-
cx={legendSize / 2}
|
|
358
|
-
cy={legendSize / 2}
|
|
359
|
-
stroke='#0000004d'
|
|
360
|
-
strokeWidth={1}
|
|
361
|
-
/>
|
|
362
|
-
</svg>
|
|
363
|
-
</span>
|
|
364
|
-
<LegendLabel align='left' className='m-0 cove-prose'>
|
|
349
|
+
<LegendShape
|
|
350
|
+
shape={config.legend.style === 'boxes' ? 'square' : 'circle'}
|
|
351
|
+
fill='white'
|
|
352
|
+
patternInfo={{
|
|
353
|
+
pattern: pattern.shape || 'circles',
|
|
354
|
+
patternId,
|
|
355
|
+
size: patternSize,
|
|
356
|
+
color: pColor
|
|
357
|
+
}}
|
|
358
|
+
/>
|
|
359
|
+
<LegendLabel align='left' className='m-0'>
|
|
365
360
|
{parse(String((pattern as any)?.label || key))}
|
|
366
361
|
</LegendLabel>
|
|
367
362
|
</LegendItem>
|
|
@@ -70,16 +70,16 @@ const LegendSuppression: React.FC<LegendProps> = ({ config, isLegendBottom }) =>
|
|
|
70
70
|
|
|
71
71
|
if (visualizationType === 'Combo') {
|
|
72
72
|
return (
|
|
73
|
-
<React.Fragment>
|
|
73
|
+
<React.Fragment key={itemKey}>
|
|
74
74
|
{pd.symbol && pd.iconCode && (
|
|
75
|
-
<div style={getStyle(pd.displayGray)}
|
|
75
|
+
<div style={getStyle(pd.displayGray)} className={`${baseClass} ${pd.symbol}`}>
|
|
76
76
|
<span className={pd.symbol}>{pd.iconCode}</span>
|
|
77
77
|
<p className={pd.type}>{pd.label}</p>
|
|
78
78
|
</div>
|
|
79
79
|
)}
|
|
80
80
|
|
|
81
81
|
{pd.style && pd.lineCode && (
|
|
82
|
-
<div style={getStyle(pd.displayGray)}
|
|
82
|
+
<div style={getStyle(pd.displayGray)} className={baseClass}>
|
|
83
83
|
<span>{pd.lineCode}</span>
|
|
84
84
|
<p>{pd.label}</p>
|
|
85
85
|
</div>
|
|
@@ -2,7 +2,6 @@ import React, { useContext } from 'react'
|
|
|
2
2
|
import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend'
|
|
3
3
|
import LegendShape from '@cdc/core/components/LegendShape'
|
|
4
4
|
import ConfigContext from '../../../ConfigContext'
|
|
5
|
-
import _ from 'lodash'
|
|
6
5
|
import './LegendGroup.styles.css'
|
|
7
6
|
|
|
8
7
|
interface LegendGroup {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { scaleOrdinal } from '@visx/scale'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { createFormatLabels } from './createFormatLabels'
|
|
4
|
+
import { ChartConfig } from '../../../types/ChartConfig'
|
|
5
|
+
import { Label } from '../../../types/Label'
|
|
6
|
+
|
|
7
|
+
const defaultLabels: Label[] = [
|
|
8
|
+
{ datum: 'Series A', index: 0, text: 'Series A', value: '#aa0000' },
|
|
9
|
+
{ datum: 'Series B', index: 1, text: 'Series B', value: '#00bb00' }
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const buildConfig = (overrides: Partial<ChartConfig> = {}): ChartConfig =>
|
|
13
|
+
({
|
|
14
|
+
visualizationType: 'Line',
|
|
15
|
+
visualizationSubType: 'regular',
|
|
16
|
+
general: {
|
|
17
|
+
palette: {
|
|
18
|
+
colorAssignmentMode: 'ordered'
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
legend: {},
|
|
22
|
+
smallMultiples: {
|
|
23
|
+
mode: 'by-series',
|
|
24
|
+
colorMode: 'same'
|
|
25
|
+
},
|
|
26
|
+
series: [
|
|
27
|
+
{ dataKey: 'series_a', name: 'Series A', type: 'Line' },
|
|
28
|
+
{ dataKey: 'series_b', name: 'Series B', type: 'Line' }
|
|
29
|
+
],
|
|
30
|
+
runtime: {
|
|
31
|
+
seriesKeys: ['series_a', 'series_b'],
|
|
32
|
+
seriesLabels: {
|
|
33
|
+
series_a: 'Series A',
|
|
34
|
+
series_b: 'Series B'
|
|
35
|
+
},
|
|
36
|
+
seriesLabelsAll: ['Series A', 'Series B']
|
|
37
|
+
},
|
|
38
|
+
...overrides
|
|
39
|
+
} as ChartConfig)
|
|
40
|
+
|
|
41
|
+
const colorScale = scaleOrdinal({
|
|
42
|
+
domain: ['Series A', 'Series B'],
|
|
43
|
+
range: ['#aa0000', '#00bb00'],
|
|
44
|
+
unknown: null
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const formatLabels = (config: ChartConfig) => createFormatLabels(config, [], [], colorScale, vi.fn())(defaultLabels)
|
|
48
|
+
|
|
49
|
+
describe('createFormatLabels small multiples legend colors', () => {
|
|
50
|
+
it('uses the first color for by-series same-color legends by default', () => {
|
|
51
|
+
const labels = formatLabels(buildConfig())
|
|
52
|
+
|
|
53
|
+
expect(labels.map(label => label.value)).toEqual(['#aa0000', '#aa0000'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('preserves assigned series colors for by-series same-color legends', () => {
|
|
57
|
+
const labels = formatLabels(
|
|
58
|
+
buildConfig({
|
|
59
|
+
general: {
|
|
60
|
+
palette: {
|
|
61
|
+
colorAssignmentMode: 'by-value',
|
|
62
|
+
colorAssignments: [
|
|
63
|
+
{ key: 'series_a', color: '#aa0000' },
|
|
64
|
+
{ key: 'series_b', color: '#00bb00' }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
} as any
|
|
68
|
+
})
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(labels.map(label => label.value)).toEqual(['#aa0000', '#00bb00'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('ignores stale assignments for by-series same-color legends', () => {
|
|
75
|
+
const labels = formatLabels(
|
|
76
|
+
buildConfig({
|
|
77
|
+
general: {
|
|
78
|
+
palette: {
|
|
79
|
+
colorAssignmentMode: 'by-value',
|
|
80
|
+
colorAssignments: [{ key: 'removed_series', color: '#00bb00' }]
|
|
81
|
+
}
|
|
82
|
+
} as any
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(labels.map(label => label.value)).toEqual(['#aa0000', '#aa0000'])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -13,6 +13,7 @@ import { v2ColorDistribution } from '@cdc/core/helpers/palettes/colorDistributio
|
|
|
13
13
|
import { updatePaletteNames } from '@cdc/core/helpers/updatePaletteNames'
|
|
14
14
|
import { buildForecastPaletteMappings } from '../../../helpers/buildForecastPaletteMappings'
|
|
15
15
|
import { getFullColorPalette } from '../../../helpers/smallMultiplesHelpers'
|
|
16
|
+
import { hasSeriesColorAssignmentOverrides } from '../../../helpers/colorAssignmentHelpers'
|
|
16
17
|
import { FaStar } from 'react-icons/fa'
|
|
17
18
|
import { Label } from '../../../types/Label'
|
|
18
19
|
import { ColorScale, TransformedData } from '../../../types/ChartContext'
|
|
@@ -36,7 +37,11 @@ export const createFormatLabels =
|
|
|
36
37
|
|
|
37
38
|
// Handle small multiples legend adjustments
|
|
38
39
|
// by-series + same: all tiles use same color, legend should show one color
|
|
39
|
-
if (
|
|
40
|
+
if (
|
|
41
|
+
config.smallMultiples?.mode === 'by-series' &&
|
|
42
|
+
config.smallMultiples?.colorMode === 'same' &&
|
|
43
|
+
!hasSeriesColorAssignmentOverrides(config)
|
|
44
|
+
) {
|
|
40
45
|
const baseColor = colorScale?.range()?.[0]
|
|
41
46
|
return defaultLabels.map((label, i) => ({
|
|
42
47
|
...label,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { createStyles, filterCircles } from './helpers'
|
|
2
|
+
import { createStyles, filterCircles, createDataSegments } from './helpers'
|
|
3
3
|
import { PreliminaryDataItem } from '../../types/ChartConfig'
|
|
4
4
|
|
|
5
5
|
describe('LineChart helpers', () => {
|
|
@@ -180,6 +180,150 @@ describe('LineChart helpers', () => {
|
|
|
180
180
|
expect(styles[2].strokeDasharray).toBe('5 5')
|
|
181
181
|
})
|
|
182
182
|
})
|
|
183
|
+
|
|
184
|
+
describe('with custom weight', () => {
|
|
185
|
+
it('should apply custom weight from preliminaryData item', () => {
|
|
186
|
+
const preliminaryDataWithWeight: PreliminaryDataItem[] = [
|
|
187
|
+
{
|
|
188
|
+
type: 'effect',
|
|
189
|
+
seriesKeys: ['COVID-19'],
|
|
190
|
+
column: 'Attribute',
|
|
191
|
+
value: 'Dotted',
|
|
192
|
+
style: 'Dashed Small',
|
|
193
|
+
label: 'COVID Dotted',
|
|
194
|
+
displayTooltip: true,
|
|
195
|
+
displayLegend: true,
|
|
196
|
+
displayTable: true,
|
|
197
|
+
symbol: '',
|
|
198
|
+
iconCode: '',
|
|
199
|
+
lineCode: '',
|
|
200
|
+
hideBarSymbol: false,
|
|
201
|
+
hideLineStyle: false,
|
|
202
|
+
circleSize: 6,
|
|
203
|
+
displayGray: false,
|
|
204
|
+
weight: 5 // Custom weight
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
const data = [
|
|
209
|
+
{ Date: '10/5/2025', Category: 'COVID-19', Value: '43.6', Attribute: 'Dotted' },
|
|
210
|
+
{ Date: '10/12/2025', Category: 'COVID-19', Value: '40.7', Attribute: '' },
|
|
211
|
+
{ Date: '10/19/2025', Category: 'COVID-19', Value: '42.6', Attribute: 'Dotted' }
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
const styles = createStyles({
|
|
215
|
+
preliminaryData: preliminaryDataWithWeight,
|
|
216
|
+
data,
|
|
217
|
+
stroke: '#000',
|
|
218
|
+
strokeWidth: 2, // Default weight
|
|
219
|
+
handleLineType: mockHandleLineType,
|
|
220
|
+
lineType: 'solid-line',
|
|
221
|
+
seriesKey: 'COVID-19',
|
|
222
|
+
dynamicCategory: 'Category',
|
|
223
|
+
originalSeriesKey: 'Value'
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
expect(styles).toHaveLength(3)
|
|
227
|
+
// First point has effect with custom weight
|
|
228
|
+
expect(styles[0].strokeWidth).toBe(5)
|
|
229
|
+
// Second point inherits custom weight from next point's effect
|
|
230
|
+
expect(styles[1].strokeWidth).toBe(5)
|
|
231
|
+
// Third point has effect with custom weight
|
|
232
|
+
expect(styles[2].strokeWidth).toBe(5)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should use default strokeWidth when preliminaryData has no weight', () => {
|
|
236
|
+
const preliminaryDataNoWeight: PreliminaryDataItem[] = [
|
|
237
|
+
{
|
|
238
|
+
type: 'effect',
|
|
239
|
+
seriesKeys: ['COVID-19'],
|
|
240
|
+
column: 'Attribute',
|
|
241
|
+
value: 'Dotted',
|
|
242
|
+
style: 'Dashed Small',
|
|
243
|
+
label: 'COVID Dotted',
|
|
244
|
+
displayTooltip: true,
|
|
245
|
+
displayLegend: true,
|
|
246
|
+
displayTable: true,
|
|
247
|
+
symbol: '',
|
|
248
|
+
iconCode: '',
|
|
249
|
+
lineCode: '',
|
|
250
|
+
hideBarSymbol: false,
|
|
251
|
+
hideLineStyle: false,
|
|
252
|
+
circleSize: 6,
|
|
253
|
+
displayGray: false
|
|
254
|
+
// No weight property
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
const data = [{ Date: '10/5/2025', Category: 'COVID-19', Value: '43.6', Attribute: 'Dotted' }]
|
|
259
|
+
|
|
260
|
+
const styles = createStyles({
|
|
261
|
+
preliminaryData: preliminaryDataNoWeight,
|
|
262
|
+
data,
|
|
263
|
+
stroke: '#000',
|
|
264
|
+
strokeWidth: 2,
|
|
265
|
+
handleLineType: mockHandleLineType,
|
|
266
|
+
lineType: 'solid-line',
|
|
267
|
+
seriesKey: 'COVID-19',
|
|
268
|
+
dynamicCategory: 'Category',
|
|
269
|
+
originalSeriesKey: 'Value'
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
expect(styles).toHaveLength(1)
|
|
273
|
+
// Should use default strokeWidth when no custom weight
|
|
274
|
+
expect(styles[0].strokeWidth).toBe(2)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it.each([
|
|
278
|
+
{ weight: 5, expected: 5, label: 'applies a valid custom weight' },
|
|
279
|
+
{ weight: 1, expected: 1, label: 'applies the minimum custom weight of 1' },
|
|
280
|
+
{ weight: 9, expected: 9, label: 'applies the maximum custom weight of 9' },
|
|
281
|
+
{ weight: 0.5, expected: 2, label: 'falls back to default for a weight below 1' },
|
|
282
|
+
{ weight: 0, expected: 2, label: 'falls back to default for a weight of 0' },
|
|
283
|
+
{ weight: -1, expected: 2, label: 'falls back to default for negative weight' },
|
|
284
|
+
{ weight: '', expected: 2, label: 'falls back to default for empty string' },
|
|
285
|
+
{ weight: 'abc', expected: 2, label: 'falls back to default for non-numeric weight' }
|
|
286
|
+
])('$label', ({ weight, expected }) => {
|
|
287
|
+
const preliminaryData: PreliminaryDataItem[] = [
|
|
288
|
+
{
|
|
289
|
+
type: 'effect',
|
|
290
|
+
seriesKeys: ['COVID-19'],
|
|
291
|
+
column: 'Attribute',
|
|
292
|
+
value: 'Dotted',
|
|
293
|
+
style: 'Dashed Small',
|
|
294
|
+
label: 'COVID Dotted',
|
|
295
|
+
displayTooltip: true,
|
|
296
|
+
displayLegend: true,
|
|
297
|
+
displayTable: true,
|
|
298
|
+
symbol: '',
|
|
299
|
+
iconCode: '',
|
|
300
|
+
lineCode: '',
|
|
301
|
+
hideBarSymbol: false,
|
|
302
|
+
hideLineStyle: false,
|
|
303
|
+
circleSize: 6,
|
|
304
|
+
displayGray: false,
|
|
305
|
+
weight: weight as PreliminaryDataItem['weight']
|
|
306
|
+
}
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
const data = [{ Date: '10/5/2025', Category: 'COVID-19', Value: '43.6', Attribute: 'Dotted' }]
|
|
310
|
+
|
|
311
|
+
const styles = createStyles({
|
|
312
|
+
preliminaryData,
|
|
313
|
+
data,
|
|
314
|
+
stroke: '#000',
|
|
315
|
+
strokeWidth: 2,
|
|
316
|
+
handleLineType: mockHandleLineType,
|
|
317
|
+
lineType: 'solid-line',
|
|
318
|
+
seriesKey: 'COVID-19',
|
|
319
|
+
dynamicCategory: 'Category',
|
|
320
|
+
originalSeriesKey: 'Value'
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
expect(styles).toHaveLength(1)
|
|
324
|
+
expect(styles[0].strokeWidth).toBe(expected)
|
|
325
|
+
})
|
|
326
|
+
})
|
|
183
327
|
})
|
|
184
328
|
|
|
185
329
|
describe('filterCircles', () => {
|
|
@@ -244,6 +388,21 @@ describe('LineChart helpers', () => {
|
|
|
244
388
|
expect(circles[1].isFilled).toBe(false)
|
|
245
389
|
})
|
|
246
390
|
|
|
391
|
+
it('should skip open circles for nonnumeric values while preserving zero values', () => {
|
|
392
|
+
const data = [
|
|
393
|
+
{ Date: '10/5/2025', Category: 'COVID-19', Value: 'N/A', Attribute: 'Marked' },
|
|
394
|
+
{ Date: '10/12/2025', Category: 'COVID-19', Value: null, Attribute: 'Marked' },
|
|
395
|
+
{ Date: '10/19/2025', Category: 'COVID-19', Value: 0, Attribute: 'Marked' },
|
|
396
|
+
{ Date: '10/26/2025', Category: 'COVID-19', Value: '12.3', Attribute: 'Marked' }
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
const circles = filterCircles(circlesPreliminaryData, data, 'COVID-19', 'Category', 'Value')
|
|
400
|
+
|
|
401
|
+
expect(circles).toHaveLength(2)
|
|
402
|
+
expect(circles.map(circle => circle.data.Value)).toEqual([0, '12.3'])
|
|
403
|
+
expect(circles.every(circle => circle.isFilled === false)).toBe(true)
|
|
404
|
+
})
|
|
405
|
+
|
|
247
406
|
it('should return empty array when no matches', () => {
|
|
248
407
|
const circles = filterCircles(
|
|
249
408
|
circlesPreliminaryData,
|
|
@@ -340,6 +499,76 @@ describe('LineChart helpers', () => {
|
|
|
340
499
|
expect(circles[0].isFilled).toBe(true)
|
|
341
500
|
expect(circles[0].size).toBe(6)
|
|
342
501
|
})
|
|
502
|
+
|
|
503
|
+
it('should skip filled circles for nonnumeric values while preserving zero values', () => {
|
|
504
|
+
const data = [
|
|
505
|
+
{ Date: '10/5/2025', Category: 'Influenza', Value: 'N/A', Attribute: 'Marked' },
|
|
506
|
+
{ Date: '10/12/2025', Category: 'Influenza', Value: null, Attribute: 'Marked' },
|
|
507
|
+
{ Date: '10/19/2025', Category: 'Influenza', Value: 0, Attribute: 'Marked' },
|
|
508
|
+
{ Date: '10/26/2025', Category: 'Influenza', Value: '12.3', Attribute: 'Marked' }
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
const circles = filterCircles(filledCirclesConfig, data, 'Influenza', 'Category', 'Value')
|
|
512
|
+
|
|
513
|
+
expect(circles).toHaveLength(2)
|
|
514
|
+
expect(circles.map(circle => circle.data.Value)).toEqual([0, '12.3'])
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
describe('createDataSegments', () => {
|
|
520
|
+
const mockColorScale = vi.fn(() => '#000')
|
|
521
|
+
|
|
522
|
+
const makeSuppression = (overrides: Partial<PreliminaryDataItem> = {}): PreliminaryDataItem => ({
|
|
523
|
+
type: 'suppression',
|
|
524
|
+
seriesKeys: ['COVID-19'],
|
|
525
|
+
column: '',
|
|
526
|
+
value: 'Suppressed',
|
|
527
|
+
style: 'Dashed Small',
|
|
528
|
+
label: 'Suppressed',
|
|
529
|
+
displayTooltip: true,
|
|
530
|
+
displayLegend: true,
|
|
531
|
+
displayTable: true,
|
|
532
|
+
symbol: '',
|
|
533
|
+
iconCode: '',
|
|
534
|
+
lineCode: '',
|
|
535
|
+
hideBarSymbol: false,
|
|
536
|
+
hideLineStyle: false,
|
|
537
|
+
circleSize: 6,
|
|
538
|
+
displayGray: false,
|
|
539
|
+
...overrides
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
const suppressionData = [
|
|
543
|
+
{ Date: '10/5/2025', 'COVID-19': '43.6' },
|
|
544
|
+
{ Date: '10/12/2025', 'COVID-19': 'Suppressed' },
|
|
545
|
+
{ Date: '10/19/2025', 'COVID-19': '42.6' }
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
it('propagates the suppression weight onto the generated segment', () => {
|
|
549
|
+
const segments = createDataSegments({
|
|
550
|
+
data: suppressionData,
|
|
551
|
+
seriesKey: 'COVID-19',
|
|
552
|
+
preliminaryData: [makeSuppression({ weight: 5 })],
|
|
553
|
+
colorScale: mockColorScale
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
const suppressedSegments = segments.filter(segment => segment.style)
|
|
557
|
+
expect(suppressedSegments.length).toBeGreaterThan(0)
|
|
558
|
+
expect(suppressedSegments.every(segment => segment.weight === 5)).toBe(true)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('leaves the segment weight undefined when no custom weight is set', () => {
|
|
562
|
+
const segments = createDataSegments({
|
|
563
|
+
data: suppressionData,
|
|
564
|
+
seriesKey: 'COVID-19',
|
|
565
|
+
preliminaryData: [makeSuppression()],
|
|
566
|
+
colorScale: mockColorScale
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
const suppressedSegments = segments.filter(segment => segment.style)
|
|
570
|
+
expect(suppressedSegments.length).toBeGreaterThan(0)
|
|
571
|
+
expect(suppressedSegments.every(segment => segment.weight === undefined)).toBe(true)
|
|
343
572
|
})
|
|
344
573
|
})
|
|
345
574
|
})
|