@cdc/chart 4.26.5 → 4.26.6

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 (91) hide show
  1. package/CONFIG.md +12 -8
  2. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  3. package/dist/cdcchart.js +34093 -32767
  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/package.json +3 -3
  10. package/src/CdcChart.tsx +5 -4
  11. package/src/CdcChartComponent.tsx +32 -9
  12. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  13. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  14. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  15. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  16. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  17. package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
  18. package/src/_stories/_mock/heatmap.json +1 -1
  19. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  20. package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
  21. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  22. package/src/components/Axis/LeftAxis.tsx +3 -0
  23. package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
  24. package/src/components/Axis/RightAxis.tsx +3 -0
  25. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  26. package/src/components/EditorPanel/EditorPanel.tsx +367 -136
  27. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
  28. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  29. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  30. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  31. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
  32. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  33. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  34. package/src/components/EditorPanel/components/panels.scss +10 -0
  35. package/src/components/EditorPanel/editor-panel.scss +47 -0
  36. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
  37. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
  38. package/src/components/EditorPanel/useEditorPermissions.test.tsx +256 -11
  39. package/src/components/EditorPanel/useEditorPermissions.ts +41 -5
  40. package/src/components/HeatMap/components/HeatMap.tsx +22 -17
  41. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
  42. package/src/components/HeatMap/heatmap.css +15 -1
  43. package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
  44. package/src/components/Legend/Legend.Component.tsx +2 -2
  45. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  46. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  47. package/src/components/LineChart/helpers.test.ts +230 -1
  48. package/src/components/LineChart/helpers.ts +26 -13
  49. package/src/components/LineChart/index.tsx +21 -4
  50. package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
  51. package/src/components/LinearChart.tsx +50 -15
  52. package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
  53. package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
  54. package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
  55. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  56. package/src/data/initial-state.js +1 -0
  57. package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
  58. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  59. package/src/helpers/dataHelpers.ts +11 -1
  60. package/src/helpers/getAxisMaxOverride.ts +16 -0
  61. package/src/helpers/getCleanTopTickMax.ts +64 -0
  62. package/src/helpers/getColorScale.ts +18 -9
  63. package/src/helpers/getHasBoundarySuppression.ts +26 -0
  64. package/src/helpers/getMinMax.ts +24 -22
  65. package/src/helpers/getYAxisDomainData.ts +38 -0
  66. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  67. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  68. package/src/helpers/seriesColumnSettings.ts +5 -4
  69. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  70. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  71. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  72. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  73. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  74. package/src/helpers/tests/getColorScale.test.ts +197 -0
  75. package/src/helpers/tests/getMinMax.test.ts +211 -0
  76. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  77. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  78. package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
  79. package/src/helpers/tests/seriesColumnSettings.test.ts +16 -2
  80. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  81. package/src/hooks/tests/useRightAxis.test.tsx +127 -0
  82. package/src/hooks/tests/useScales.test.tsx +307 -29
  83. package/src/hooks/useRightAxis.ts +30 -13
  84. package/src/hooks/useScales.ts +45 -28
  85. package/src/hooks/useTooltip.tsx +3 -1
  86. package/src/test/CdcChart.dataTable.test.tsx +122 -0
  87. package/src/types/ChartConfig.ts +6 -1
  88. package/src/types/ChartContext.ts +3 -0
  89. package/src/helpers/getYAxisAutoPadding.ts +0 -55
  90. package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
  91. 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
  })
@@ -242,7 +242,7 @@ const Legend: React.FC<LegendProps> = forwardRef(
242
242
  </>
243
243
  )}
244
244
  </>
245
- <LegendLabel align='left' className='m-0 cove-prose'>
245
+ <LegendLabel align='left' className='m-0'>
246
246
  {parse(label.text)}
247
247
  </LegendLabel>
248
248
  </LegendItem>
@@ -361,7 +361,7 @@ const Legend: React.FC<LegendProps> = forwardRef(
361
361
  />
362
362
  </svg>
363
363
  </span>
364
- <LegendLabel align='left' className='m-0 cove-prose'>
364
+ <LegendLabel align='left' className='m-0'>
365
365
  {parse(String((pattern as any)?.label || key))}
366
366
  </LegendLabel>
367
367
  </LegendItem>
@@ -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
  })
@@ -2,6 +2,9 @@ import { DataItem, StyleProps, Style } from './LineChartProps'
2
2
  import { PreliminaryDataItem } from '../../types/ChartConfig'
3
3
 
4
4
  import _ from 'lodash'
5
+
6
+ const isCalculable = value => !isNaN(parseFloat(value)) && isFinite(value)
7
+
5
8
  export const createStyles = (props: StyleProps): Style[] => {
6
9
  const {
7
10
  preliminaryData,
@@ -42,24 +45,29 @@ export const createStyles = (props: StyleProps): Style[] => {
42
45
  validPreliminaryData.find(pd => isEffectLine(pd, point))
43
46
 
44
47
  const styles: Style[] = []
45
- const createStyle = (lineStyle): Style => ({
46
- stroke: stroke,
47
- strokeWidth: strokeWidth,
48
- strokeDasharray: lineStyle
49
- })
48
+ const createStyle = (lineStyle, customWeight?: number | string): Style => {
49
+ const parsedWeight = Number(customWeight)
50
+ const hasValidWeight =
51
+ customWeight !== '' && customWeight != null && Number.isFinite(parsedWeight) && parsedWeight >= 1
52
+ return {
53
+ stroke: stroke,
54
+ strokeWidth: hasValidWeight ? parsedWeight : strokeWidth,
55
+ strokeDasharray: lineStyle
56
+ }
57
+ }
50
58
 
51
59
  data.forEach((d, index) => {
52
60
  const matchingPd: PreliminaryDataItem = getMatchingPd(d)
53
61
 
54
62
  let style: Style = matchingPd
55
- ? createStyle(handleLineType(matchingPd.style))
63
+ ? createStyle(handleLineType(matchingPd.style), matchingPd.weight)
56
64
  : createStyle(handleLineType(lineType))
57
65
 
58
66
  styles.push(style)
59
67
 
60
68
  // If matchingPd exists, update the previous style if there is a previous element
61
69
  if (matchingPd && index > 0) {
62
- styles[index - 1] = createStyle(handleLineType(matchingPd.style))
70
+ styles[index - 1] = createStyle(handleLineType(matchingPd.style), matchingPd.weight)
63
71
  }
64
72
  })
65
73
  return styles as Style[]
@@ -96,7 +104,7 @@ export const filterCircles = (
96
104
  if (
97
105
  item[fc.column] === fc.value &&
98
106
  fc.seriesKeys.includes(seriesKey) &&
99
- item[valueKey] &&
107
+ isCalculable(item[valueKey]) &&
100
108
  fc.style === 'Open Circles'
101
109
  ) {
102
110
  const result = {
@@ -109,7 +117,7 @@ export const filterCircles = (
109
117
  if (
110
118
  (!fc.value || item[fc.column] === fc.value) &&
111
119
  fc.seriesKeys.includes(seriesKey) &&
112
- item[valueKey] &&
120
+ isCalculable(item[valueKey]) &&
113
121
  fc.style === 'Filled Circles'
114
122
  ) {
115
123
  const result = {
@@ -125,7 +133,6 @@ export const filterCircles = (
125
133
  return filteredData
126
134
  }
127
135
 
128
- const isCalculable = value => !isNaN(parseFloat(value)) && isFinite(value)
129
136
  const handleFirstIndex = ({
130
137
  data,
131
138
  seriesKey,
@@ -139,7 +146,8 @@ const handleFirstIndex = ({
139
146
  const result = {
140
147
  data: { '0': [] },
141
148
  style: '',
142
- color: ''
149
+ color: '',
150
+ weight: undefined
143
151
  }
144
152
 
145
153
  // If data is empty, return the empty result
@@ -160,6 +168,7 @@ const handleFirstIndex = ({
160
168
 
161
169
  result.data[pairCount].push(modifiedItem)
162
170
  result.style = suppressionData.style
171
+ result.weight = suppressionData.weight
163
172
  result.color = dynamicCategory && modifiedItem ? colorScale(modifiedItem[dynamicCategory]) : ''
164
173
 
165
174
  // Find the next calculable item index
@@ -190,7 +199,8 @@ const handleLastIndex = ({
190
199
  const result = {
191
200
  data: { '0': [] },
192
201
  style: '',
193
- color: ''
202
+ color: '',
203
+ weight: undefined
194
204
  }
195
205
  const lastIndexDataItem = data[data.length - 1]
196
206
 
@@ -213,6 +223,7 @@ const handleLastIndex = ({
213
223
  lastAddedIndex = prevIndex
214
224
  }
215
225
  result.style = pd.style
226
+ result.weight = pd.weight
216
227
  result.color = colorScale(modifiedItem[dynamicCategory])
217
228
  }
218
229
  })
@@ -232,7 +243,8 @@ const handleMiddleIndices = ({
232
243
  let result = {
233
244
  data: {},
234
245
  style: '',
235
- color: 'red'
246
+ color: 'red',
247
+ weight: undefined
236
248
  }
237
249
 
238
250
  //skip processing if data or preliminaryData is not an array
@@ -270,6 +282,7 @@ const handleMiddleIndices = ({
270
282
  // Only add siblings to results if both siblings are found
271
283
  if (siblingBefore && siblingAfter) {
272
284
  result.style = pd.style
285
+ result.weight = pd.weight
273
286
  result.color = colorScale(item[dynamicCategory])
274
287
  result.data[pairCount++] = [siblingBefore, siblingAfter]
275
288
  }
@@ -18,6 +18,7 @@ import { filterCircles, createStyles, createDataSegments } from './helpers'
18
18
  import { getLabelPositionForDataPoint } from './helpers/labelPositioning'
19
19
  import LineChartCircle from './components/LineChart.Circle'
20
20
  import LineChartBumpCircle from './components/LineChart.BumpCircle'
21
+ import { getYAxisDomainData } from '../../helpers/getYAxisDomainData'
21
22
  import isNumber from '@cdc/core/helpers/isNumber'
22
23
 
23
24
  // Types
@@ -39,9 +40,25 @@ const LineChart = (props: LineChartProps) => {
39
40
  yAxisWidth,
40
41
  } = props
41
42
 
42
- // prettier-ignore
43
- const { colorScale, config, formatNumber, handleLineType, parseDate, seriesHighlight, tableData, transformedData:data, updateConfig, } = useContext<ChartContext>(ConfigContext)
44
- const { yScaleRight } = useRightAxis({ config, yMax, data, updateConfig })
43
+ const {
44
+ colorScale,
45
+ config,
46
+ formatNumber,
47
+ handleLineType,
48
+ parseDate,
49
+ seriesHighlight,
50
+ tableData,
51
+ transformedData: data,
52
+ updateConfig,
53
+ yAxisDomainData
54
+ } = useContext<ChartContext>(ConfigContext)
55
+ const dataForRightAxis = getYAxisDomainData({
56
+ config,
57
+ data,
58
+ tableData,
59
+ fullEligibleDomainData: yAxisDomainData
60
+ })
61
+ const { yScaleRight } = useRightAxis({ config, yMax, data: dataForRightAxis, updateConfig })
45
62
  const showSingleSeries = config.tooltips.singleSeries
46
63
  if (!handleTooltipMouseOver) return
47
64
 
@@ -302,7 +319,7 @@ const LineChart = (props: LineChartProps) => {
302
319
  ? segment.color
303
320
  : colorScale(config.runtime.seriesLabels[seriesKey])
304
321
  }
305
- strokeWidth={seriesData[0]?.weight || 2}
322
+ strokeWidth={Number(segment.weight) || seriesData.weight || 2}
306
323
  strokeOpacity={1}
307
324
  shapeRendering='geometricPrecision'
308
325
  strokeDasharray={handleLineType(segment.style)}