@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.
Files changed (128) hide show
  1. package/CONFIG.md +20 -14
  2. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  3. package/dist/cdcchart.js +36554 -34320
  4. package/examples/feature/heatmap/average-age-categorical.json +1 -1
  5. package/examples/feature/heatmap/calendar-heatmap.json +1 -1
  6. package/examples/feature/heatmap/sparse-aggregation.json +1 -1
  7. package/examples/private/bad-y-axis.json +543 -0
  8. package/examples/private/bar-chart-negative-value.json +455 -0
  9. package/examples/private/flueview.json +22051 -0
  10. package/package.json +3 -11
  11. package/src/CdcChart.tsx +7 -6
  12. package/src/CdcChartComponent.tsx +71 -18
  13. package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
  14. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  15. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  16. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  17. package/src/_stories/Chart.smoke.stories.tsx +14 -1
  18. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  19. package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
  20. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  21. package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
  22. package/src/_stories/_mock/heatmap.json +1 -1
  23. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  24. package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
  25. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  26. package/src/components/Axis/LeftAxis.tsx +3 -0
  27. package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
  28. package/src/components/Axis/RightAxis.tsx +3 -0
  29. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  30. package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
  31. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
  32. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
  33. package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
  34. package/src/components/BarChart/helpers/getBarData.ts +28 -28
  35. package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
  36. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
  37. package/src/components/BarChart/helpers/useBarChart.ts +4 -5
  38. package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
  39. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
  40. package/src/components/BoxPlot/helpers/index.ts +4 -3
  41. package/src/components/Brush/BrushSelector.tsx +1 -1
  42. package/src/components/EditorPanel/EditorPanel.tsx +457 -165
  43. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
  44. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
  46. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
  47. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  48. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  49. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  50. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
  51. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  52. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  53. package/src/components/EditorPanel/components/panels.scss +10 -0
  54. package/src/components/EditorPanel/editor-panel.scss +47 -0
  55. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
  56. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
  57. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
  58. package/src/components/EditorPanel/useEditorPermissions.test.tsx +288 -11
  59. package/src/components/EditorPanel/useEditorPermissions.ts +46 -6
  60. package/src/components/Forecasting/Forecasting.tsx +1 -1
  61. package/src/components/HeatMap/components/HeatMap.tsx +22 -17
  62. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
  63. package/src/components/HeatMap/heatmap.css +15 -1
  64. package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
  65. package/src/components/Legend/Legend.Component.tsx +56 -61
  66. package/src/components/Legend/Legend.Suppression.tsx +3 -3
  67. package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
  68. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  69. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  70. package/src/components/LineChart/helpers.test.ts +230 -1
  71. package/src/components/LineChart/helpers.ts +25 -14
  72. package/src/components/LineChart/index.tsx +23 -7
  73. package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
  74. package/src/components/LinearChart.tsx +51 -15
  75. package/src/components/PieChart/PieChart.tsx +117 -10
  76. package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
  77. package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
  78. package/src/components/ScatterPlot/ScatterPlot.jsx +5 -13
  79. package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
  80. package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
  81. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  82. package/src/data/initial-state.js +3 -0
  83. package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
  84. package/src/helpers/categoryOrder.ts +60 -0
  85. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  86. package/src/helpers/dataHelpers.ts +11 -1
  87. package/src/helpers/getAxisMaxOverride.ts +16 -0
  88. package/src/helpers/getBoxPlotConfig.ts +19 -12
  89. package/src/helpers/getCleanTopTickMax.ts +64 -0
  90. package/src/helpers/getColorScale.ts +18 -9
  91. package/src/helpers/getComboChartConfig.ts +0 -1
  92. package/src/helpers/getExcludedData.ts +0 -1
  93. package/src/helpers/getHasBoundarySuppression.ts +27 -0
  94. package/src/helpers/getMinMax.ts +25 -22
  95. package/src/helpers/getNewRuntime.ts +0 -2
  96. package/src/helpers/getPiePercent.ts +3 -3
  97. package/src/helpers/getYAxisDomainData.ts +38 -0
  98. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  99. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  100. package/src/helpers/isConvertLineToBarGraph.ts +2 -2
  101. package/src/helpers/seriesColumnSettings.ts +26 -7
  102. package/src/helpers/sizeHelpers.ts +0 -2
  103. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  104. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  105. package/src/helpers/tests/categoryOrder.test.ts +57 -0
  106. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  107. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  108. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  109. package/src/helpers/tests/getColorScale.test.ts +197 -0
  110. package/src/helpers/tests/getMinMax.test.ts +211 -0
  111. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  112. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  113. package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
  114. package/src/helpers/tests/seriesColumnSettings.test.ts +76 -2
  115. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  116. package/src/hooks/tests/useRightAxis.test.tsx +268 -0
  117. package/src/hooks/tests/useScales.test.tsx +302 -27
  118. package/src/hooks/tests/useTooltip.test.tsx +132 -0
  119. package/src/hooks/useRightAxis.ts +35 -25
  120. package/src/hooks/useScales.ts +45 -28
  121. package/src/hooks/useTooltip.tsx +17 -11
  122. package/src/store/chart.reducer.ts +0 -1
  123. package/src/test/CdcChart.dataTable.test.tsx +216 -0
  124. package/src/types/ChartConfig.ts +6 -1
  125. package/src/types/ChartContext.ts +3 -0
  126. package/src/helpers/getYAxisAutoPadding.ts +0 -55
  127. package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
  128. 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: 'smooth',
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: 'smooth',
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(screen.getByText('2')).toBeTruthy()
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(1)
1023
- expect(screen.getAllByText('Hide Ticks')).toHaveLength(1)
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
- {(legend.label || legend.description) && (
119
- <div className={legend.description ? 'mb-3' : 'mb-2'}>
120
- {legend.label && <h3 className='fw-bold cove-prose'>{parse(legend.label)}</h3>}
121
- {legend.description && <p className='mt-2 cove-prose'>{parse(legend.description)}</p>}
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 cove-prose'>
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
- <span className='me-2'>
313
- <svg width={legendSize} height={legendSize}>
314
- <defs>
315
- {pattern.shape === 'circles' && (
316
- <PatternCircles
317
- id={patternId}
318
- height={size}
319
- width={size}
320
- fill={pColor}
321
- radius={1.25}
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)} key={itemKey} className={`${baseClass} ${pd.symbol}`}>
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)} key={itemKey} className={baseClass}>
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 (config.smallMultiples?.mode === 'by-series' && config.smallMultiples?.colorMode === 'same') {
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
  })