@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
@@ -1,7 +1,8 @@
1
1
  import { DataItem, StyleProps, Style } from './LineChartProps'
2
2
  import { PreliminaryDataItem } from '../../types/ChartConfig'
3
3
 
4
- import _ from 'lodash'
4
+ const isCalculable = value => !isNaN(parseFloat(value)) && isFinite(value)
5
+
5
6
  export const createStyles = (props: StyleProps): Style[] => {
6
7
  const {
7
8
  preliminaryData,
@@ -42,24 +43,29 @@ export const createStyles = (props: StyleProps): Style[] => {
42
43
  validPreliminaryData.find(pd => isEffectLine(pd, point))
43
44
 
44
45
  const styles: Style[] = []
45
- const createStyle = (lineStyle): Style => ({
46
- stroke: stroke,
47
- strokeWidth: strokeWidth,
48
- strokeDasharray: lineStyle
49
- })
46
+ const createStyle = (lineStyle, customWeight?: number | string): Style => {
47
+ const parsedWeight = Number(customWeight)
48
+ const hasValidWeight =
49
+ customWeight !== '' && customWeight != null && Number.isFinite(parsedWeight) && parsedWeight >= 1
50
+ return {
51
+ stroke: stroke,
52
+ strokeWidth: hasValidWeight ? parsedWeight : strokeWidth,
53
+ strokeDasharray: lineStyle
54
+ }
55
+ }
50
56
 
51
57
  data.forEach((d, index) => {
52
58
  const matchingPd: PreliminaryDataItem = getMatchingPd(d)
53
59
 
54
60
  let style: Style = matchingPd
55
- ? createStyle(handleLineType(matchingPd.style))
61
+ ? createStyle(handleLineType(matchingPd.style), matchingPd.weight)
56
62
  : createStyle(handleLineType(lineType))
57
63
 
58
64
  styles.push(style)
59
65
 
60
66
  // If matchingPd exists, update the previous style if there is a previous element
61
67
  if (matchingPd && index > 0) {
62
- styles[index - 1] = createStyle(handleLineType(matchingPd.style))
68
+ styles[index - 1] = createStyle(handleLineType(matchingPd.style), matchingPd.weight)
63
69
  }
64
70
  })
65
71
  return styles as Style[]
@@ -96,7 +102,7 @@ export const filterCircles = (
96
102
  if (
97
103
  item[fc.column] === fc.value &&
98
104
  fc.seriesKeys.includes(seriesKey) &&
99
- item[valueKey] &&
105
+ isCalculable(item[valueKey]) &&
100
106
  fc.style === 'Open Circles'
101
107
  ) {
102
108
  const result = {
@@ -109,7 +115,7 @@ export const filterCircles = (
109
115
  if (
110
116
  (!fc.value || item[fc.column] === fc.value) &&
111
117
  fc.seriesKeys.includes(seriesKey) &&
112
- item[valueKey] &&
118
+ isCalculable(item[valueKey]) &&
113
119
  fc.style === 'Filled Circles'
114
120
  ) {
115
121
  const result = {
@@ -125,7 +131,6 @@ export const filterCircles = (
125
131
  return filteredData
126
132
  }
127
133
 
128
- const isCalculable = value => !isNaN(parseFloat(value)) && isFinite(value)
129
134
  const handleFirstIndex = ({
130
135
  data,
131
136
  seriesKey,
@@ -139,7 +144,8 @@ const handleFirstIndex = ({
139
144
  const result = {
140
145
  data: { '0': [] },
141
146
  style: '',
142
- color: ''
147
+ color: '',
148
+ weight: undefined
143
149
  }
144
150
 
145
151
  // If data is empty, return the empty result
@@ -160,6 +166,7 @@ const handleFirstIndex = ({
160
166
 
161
167
  result.data[pairCount].push(modifiedItem)
162
168
  result.style = suppressionData.style
169
+ result.weight = suppressionData.weight
163
170
  result.color = dynamicCategory && modifiedItem ? colorScale(modifiedItem[dynamicCategory]) : ''
164
171
 
165
172
  // Find the next calculable item index
@@ -190,7 +197,8 @@ const handleLastIndex = ({
190
197
  const result = {
191
198
  data: { '0': [] },
192
199
  style: '',
193
- color: ''
200
+ color: '',
201
+ weight: undefined
194
202
  }
195
203
  const lastIndexDataItem = data[data.length - 1]
196
204
 
@@ -213,6 +221,7 @@ const handleLastIndex = ({
213
221
  lastAddedIndex = prevIndex
214
222
  }
215
223
  result.style = pd.style
224
+ result.weight = pd.weight
216
225
  result.color = colorScale(modifiedItem[dynamicCategory])
217
226
  }
218
227
  })
@@ -232,7 +241,8 @@ const handleMiddleIndices = ({
232
241
  let result = {
233
242
  data: {},
234
243
  style: '',
235
- color: 'red'
244
+ color: 'red',
245
+ weight: undefined
236
246
  }
237
247
 
238
248
  //skip processing if data or preliminaryData is not an array
@@ -270,6 +280,7 @@ const handleMiddleIndices = ({
270
280
  // Only add siblings to results if both siblings are found
271
281
  if (siblingBefore && siblingAfter) {
272
282
  result.style = pd.style
283
+ result.weight = pd.weight
273
284
  result.color = colorScale(item[dynamicCategory])
274
285
  result.data[pairCount++] = [siblingBefore, siblingAfter]
275
286
  }
@@ -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)}
@@ -464,9 +481,7 @@ const LineChart = (props: LineChartProps) => {
464
481
  break
465
482
  }
466
483
  }
467
- if (!lastDatum || legend.position === 'right') {
468
- return <></>
469
- }
484
+ if (!lastDatum || legend.position === 'right') return null
470
485
 
471
486
  let labelText = config.runtime.seriesLabels[seriesKey] || seriesKey
472
487
  // truncate labels longer that 10 chars
@@ -477,6 +492,7 @@ const LineChart = (props: LineChartProps) => {
477
492
 
478
493
  return (
479
494
  <Text
495
+ key={seriesKey}
480
496
  display={
481
497
  legend.behavior === 'highlight' ||
482
498
  (seriesHighlight.length === 0 && !legend.dynamicLegend) ||
@@ -5,6 +5,7 @@ import LinearChart from '../../LinearChart'
5
5
  import ConfigContext from '../../../ConfigContext'
6
6
  import { createMockChartContext } from './mockConfigContext'
7
7
  import forestPlotConfig from '../../../../examples/feature/forest-plot/forest-plot.json'
8
+ import * as suppressionHelpers from '../../../helpers/getHasBoundarySuppression'
8
9
 
9
10
  // Mock ResizeObserver
10
11
  vi.stubGlobal(
@@ -88,6 +89,35 @@ const sidePlacementYAxis = {
88
89
  }
89
90
 
90
91
  describe('LinearChart', () => {
92
+ describe('suppression domain data source', () => {
93
+ it('uses excludedData fallback for suppression detection when Y-axis domain is stable and yAxisDomainData is not provided', () => {
94
+ const suppressionSpy = vi.spyOn(suppressionHelpers, 'getHasBoundarySuppression').mockReturnValue(false)
95
+ const excludedRows = [{ Date: '2020-01-01', Cases: 'SUPP' }]
96
+ const tableRows = [{ Date: '2020-01-01', Cases: 12 }]
97
+
98
+ renderLinearChart(
99
+ {
100
+ visualizationType: 'Line',
101
+ yAxis: {
102
+ ...createMockChartContext().config.yAxis,
103
+ filterDomainBehavior: 'stable'
104
+ }
105
+ },
106
+ {
107
+ transformedData: tableRows as any,
108
+ tableData: tableRows as any,
109
+ excludedData: excludedRows as any,
110
+ yAxisDomainData: undefined
111
+ }
112
+ )
113
+
114
+ expect(suppressionSpy).toHaveBeenCalled()
115
+ expect(suppressionSpy.mock.calls[0][0].rows).toBe(excludedRows)
116
+
117
+ suppressionSpy.mockRestore()
118
+ })
119
+ })
120
+
91
121
  describe('rendering', () => {
92
122
  it('renders without crashing', () => {
93
123
  const { container } = renderLinearChart()
@@ -37,7 +37,10 @@ import { calcInitialHeight } from '../helpers/sizeHelpers'
37
37
  import { calculateHorizontalBarCategoryLabelWidth } from '../helpers/calculateHorizontalBarCategoryLabelWidth'
38
38
  import { calculateLeftYAxisWidth } from '../helpers/calculateLeftYAxisWidth'
39
39
  import { getAxisLabelFontSize } from '../helpers/axisLabelFontSize'
40
- import { getYAxisAutoPaddingMode } from '../helpers/getYAxisAutoPaddingMode'
40
+ import { hasSpacedInlineLabel } from '../helpers/hasSpacedInlineLabel'
41
+ import { getYAxisDomainData, getYAxisFilterDomainBehavior } from '../helpers/getYAxisDomainData'
42
+ import { getHasBoundarySuppression } from '../helpers/getHasBoundarySuppression'
43
+ import { getExcludedData } from '../helpers/getExcludedData'
41
44
 
42
45
  // Hooks
43
46
  import useReduceData from '../hooks/useReduceData'
@@ -117,8 +120,11 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
117
120
  legendRef,
118
121
  parseDate,
119
122
  parentRef,
123
+ excludedData,
120
124
  tableData,
121
125
  transformedData: data,
126
+ yAxisTickValues: sharedYAxisTickValues,
127
+ yAxisDomainData
122
128
  } = useContext(ConfigContext)
123
129
 
124
130
  // SVG accessibility: title/desc pattern
@@ -131,8 +137,29 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
131
137
  const { inlineLabel } = config.yAxis
132
138
 
133
139
  // HOOKS % STATES
134
- const useBrushFullRange = config.xAxis.brushActive && !config.xAxis.brushDynamicYAxis
135
- const dataForMinMax = useBrushFullRange && tableData && tableData.length > 0 ? tableData : data
140
+ const dataForMinMax = getYAxisDomainData({
141
+ config,
142
+ data,
143
+ tableData,
144
+ fullEligibleDomainData: yAxisDomainData
145
+ })
146
+ const isStableYAxisDomain = getYAxisFilterDomainBehavior(config) === 'stable'
147
+ const rawEligibleYAxisDomainData =
148
+ Array.isArray(config.yAxisDomainData) && config.yAxisDomainData.length > 0
149
+ ? getExcludedData(config, config.yAxisDomainData)
150
+ : isStableYAxisDomain && Array.isArray(excludedData)
151
+ ? excludedData
152
+ : tableData
153
+ const suppressionDomainData = getYAxisDomainData({
154
+ config,
155
+ data: tableData,
156
+ tableData,
157
+ fullEligibleDomainData: rawEligibleYAxisDomainData
158
+ })
159
+ const hasBoundarySuppression = getHasBoundarySuppression({
160
+ rows: suppressionDomainData,
161
+ config
162
+ })
136
163
  const { minValue, maxValue, existPositiveValue, isAllLine } = useReduceData(config, dataForMinMax)
137
164
 
138
165
  const { visSupportsSmallMultiples } = useEditorPermissions()
@@ -177,7 +204,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
177
204
  const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
178
205
  const isForestPlot = visualizationType === 'Forest Plot'
179
206
  const isDateTime = config.xAxis.type === 'date-time'
180
- const yAxisAutoPaddingMode = getYAxisAutoPaddingMode(config)
207
+ const usesSpacedInlineLabel = hasSpacedInlineLabel(config)
181
208
  const tickLabelFontSize = isMobileFontViewport(vizViewport) ? TICK_LABEL_FONT_SIZE_SMALL : TICK_LABEL_FONT_SIZE
182
209
  const axisLabelFontSize = getAxisLabelFontSize(vizViewport)
183
210
  const GET_TEXT_WIDTH_FONT = `normal ${tickLabelFontSize}px Nunito, sans-serif`
@@ -255,14 +282,14 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
255
282
  return () => observer.disconnect()
256
283
  }, [axisBottomRef.current])
257
284
 
258
- const { yScaleRight, hasRightAxis } = useRightAxis({ config, yMax, data })
285
+ const { yScaleRight, hasRightAxis, rightTickValues } = useRightAxis({ config, yMax, data: dataForMinMax })
259
286
 
260
287
  // State for computed left-axis width - shared across all linear-chart types.
261
288
  const [currentYAxisWidth, setCurrentYAxisWidth] = useState<number>(DEFAULT_LEFT_Y_AXIS_WIDTH)
262
289
  const yAxisWidth = currentYAxisWidth
263
290
 
264
291
  // Chart width calculation using the current y-axis width
265
- const xMax = parentWidth - yAxisWidth - (hasRightAxis ? config.yAxis.rightAxisSize : 0)
292
+ const xMax = Math.max(0, parentWidth - yAxisWidth - (hasRightAxis ? config.yAxis.rightAxisSize : 0))
266
293
 
267
294
  // Stabilize brush container dimensions when brushDynamicYAxis is enabled.
268
295
  // Without this, y-axis rescaling on each brush change creates a feedback loop:
@@ -289,10 +316,13 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
289
316
  xScaleAnnotation,
290
317
  yScaleAnnotation,
291
318
  min,
292
- max
319
+ max,
320
+ yTickValues
293
321
  } = useScales({
294
322
  data,
295
- tableData,
323
+ tableData: dataForMinMax,
324
+ yAxisDomainData: dataForMinMax,
325
+ hasBoundarySuppression,
296
326
  config,
297
327
  minValue,
298
328
  maxValue,
@@ -301,9 +331,10 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
301
331
  xAxisDataMapped,
302
332
  yMax,
303
333
  xMax,
304
- yAxisAutoPaddingMode,
334
+ hasSpacedInlineLabel: usesSpacedInlineLabel,
305
335
  currentViewport: vizViewport
306
336
  })
337
+ const effectiveYTickValues = sharedYAxisTickValues ?? yTickValues
307
338
 
308
339
  // Consolidated tick formatters
309
340
  const { handleLeftTickFormatting, handleBottomTickFormatting } = useTickFormatters({
@@ -358,6 +389,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
358
389
  data,
359
390
  yScale,
360
391
  numTicks: handleNumTicks,
392
+ tickValues: effectiveYTickValues,
361
393
  parentWidth,
362
394
  tickLabelFont: GET_TEXT_WIDTH_FONT,
363
395
  axisLabelFontSize,
@@ -369,6 +401,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
369
401
  config,
370
402
  data,
371
403
  yScale,
404
+ effectiveYTickValues,
372
405
  handleNumTicks,
373
406
  parentWidth,
374
407
  isHorizontal,
@@ -596,6 +629,8 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
596
629
  <SmallMultiples
597
630
  config={config}
598
631
  data={data}
632
+ yAxisDomainData={dataForMinMax}
633
+ hasBoundarySuppression={hasBoundarySuppression}
599
634
  svgRef={svgRef}
600
635
  parentWidth={parentWidth}
601
636
  parentHeight={parentHeight}
@@ -617,9 +652,8 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
617
652
  onMouseMove={onMouseMove}
618
653
  width={parentWidth}
619
654
  height={isNoDataAvailable ? 1 : calculatedSvgHeight ?? parentHeight}
620
- className={`linear ${config.animate ? 'animated' : ''} ${animatedChart && config.animate ? 'animate' : ''} ${
621
- debugSvg && 'debug'
622
- } ${isDraggingAnnotation && 'dragging-annotation'}`}
655
+ className={`linear ${config.animate ? 'animated' : ''} ${animatedChart && config.animate ? 'animate' : ''} ${debugSvg && 'debug'
656
+ } ${isDraggingAnnotation && 'dragging-annotation'}`}
623
657
  role='img'
624
658
  aria-label={a11y}
625
659
  style={{ overflow: 'visible' }}
@@ -642,6 +676,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
642
676
  xMax={xMax}
643
677
  yAxisWidth={yAxisWidth}
644
678
  numTicks={handleNumTicks}
679
+ tickValues={effectiveYTickValues}
645
680
  axisLabelFontSize={axisLabelFontSize}
646
681
  />
647
682
  )}
@@ -838,6 +873,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
838
873
  xMax={xMax}
839
874
  yAxisWidth={yAxisWidth}
840
875
  numTicks={handleNumTicks}
876
+ tickValues={effectiveYTickValues}
841
877
  tickLabelFontSize={tickLabelFontSize}
842
878
  axisLabelFontSize={axisLabelFontSize}
843
879
  handleLeftTickFormatting={handleLeftTickFormatting}
@@ -863,6 +899,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
863
899
  yMax={yMax}
864
900
  xMax={xMax}
865
901
  yAxisWidth={yAxisWidth}
902
+ tickValues={rightTickValues}
866
903
  tickLabelFontSize={tickLabelFontSize}
867
904
  axisLabelFontSize={axisLabelFontSize}
868
905
  />
@@ -914,9 +951,8 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
914
951
  tooltipData.dataXPosition &&
915
952
  !config.tooltips.singleSeries && (
916
953
  <>
917
- <style>{`.tooltip {background-color: rgba(255,255,255, ${
918
- config.tooltips.opacity / 100
919
- }) !important;`}</style>
954
+ <style>{`.tooltip {background-color: rgba(255,255,255, ${config.tooltips.opacity / 100
955
+ }) !important;`}</style>
920
956
  <style>{`.tooltip {max-width:300px} !important; word-wrap: break-word; `}</style>
921
957
  <TooltipWithBounds
922
958
  ref={tooltipRef}
@@ -4,7 +4,6 @@ import { animated, useTransition, to } from '@react-spring/web'
4
4
  // visx
5
5
  import { Pie } from '@visx/shape'
6
6
  import { Group } from '@visx/group'
7
- import { Text } from '@visx/text'
8
7
  import { useTooltip, TooltipWithBounds } from '@visx/tooltip'
9
8
  import { colorPalettesChart as colorPalettes } from '@cdc/core/data/colorPalettes'
10
9
  import { getPaletteColors } from '@cdc/core/helpers/palettes/utils'
@@ -25,6 +24,8 @@ import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
25
24
  import { scaleOrdinal } from '@visx/scale'
26
25
  import { getContrastColor } from '@cdc/core/helpers/cove/accessibility'
27
26
  import { type TooltipDisplayData } from '../../helpers/tooltipHelpers'
27
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
28
+ import { getPieLabelPosition } from './helpers/labelPlacement'
28
29
 
29
30
  type TooltipData = TooltipDisplayData
30
31
 
@@ -34,6 +35,38 @@ type PieChartProps = {
34
35
  interactionLabel?: string
35
36
  }
36
37
 
38
+ const ENHANCED_PIE_LABEL_FONT_SIZE = 13
39
+ const ENHANCED_PIE_LABEL_LINE_HEIGHT = 15
40
+ const ENHANCED_PIE_LABEL_HEIGHT = ENHANCED_PIE_LABEL_LINE_HEIGHT * 2
41
+ const ENHANCED_PIE_LABEL_FONT = `${ENHANCED_PIE_LABEL_FONT_SIZE}px sans-serif`
42
+ const ENHANCED_PIE_LABEL_MIN_ANGLE = 0.01
43
+ const LEGACY_PERCENT_LABEL_MIN_ANGLE = 0.1
44
+ const MIN_RADIUS_WITH_LABELS = 55
45
+ const MIN_LABEL_GUTTER = 96
46
+ const OUTSIDE_LABEL_OFFSET = 22
47
+ const LABEL_GUTTER_PADDING = OUTSIDE_LABEL_OFFSET + 16
48
+ const VERTICAL_LABEL_GUTTER = 44
49
+
50
+ const pieLabelTextWidthCache = new Map<string, number>()
51
+ const MAX_PIE_LABEL_TEXT_CACHE = 2000
52
+
53
+ const measurePieLabelText = (text: string) => {
54
+ const cached = pieLabelTextWidthCache.get(text)
55
+ if (cached !== undefined) return cached
56
+
57
+ const measured =
58
+ typeof document === 'undefined'
59
+ ? Math.ceil(text.length * ENHANCED_PIE_LABEL_FONT_SIZE * 0.6)
60
+ : (getTextWidth(text, ENHANCED_PIE_LABEL_FONT) ??
61
+ Math.ceil(text.length * ENHANCED_PIE_LABEL_FONT_SIZE * 0.6))
62
+
63
+ // Avoid unbounded growth in long-lived sessions with many unique labels.
64
+ if (pieLabelTextWidthCache.size >= MAX_PIE_LABEL_TEXT_CACHE) pieLabelTextWidthCache.clear()
65
+
66
+ pieLabelTextWidthCache.set(text, measured)
67
+ return measured
68
+ }
69
+
37
70
  const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) => {
38
71
  const { interactionLabel = '' } = props
39
72
  const {
@@ -70,6 +103,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
70
103
  const pivotKey = dataNeedsPivot ? 'pivotColumn' : undefined
71
104
  const showPercentage = config.dataFormat.showPiePercent
72
105
  const labelForCalcArea = 'Calculated Area'
106
+ const showCategoryPercentageLabels = Boolean(config.labels)
73
107
 
74
108
  const _data = useMemo(() => {
75
109
  let baseData = []
@@ -269,9 +303,25 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
269
303
 
270
304
  let roundedPercentage = percentageToDisplay.toFixed(roundTo) + '%'
271
305
  // add missing pie part
272
- if (arc.data[config.xAxis.dataKey] === labelForCalcArea && config.dataFormat.showPiePercent) {
306
+ if (arc.data[config.runtime.xAxis.dataKey] === labelForCalcArea && config.dataFormat.showPiePercent) {
273
307
  roundedPercentage = '**'
274
308
  }
309
+ let categoryLabel = ''
310
+ let labelPosition = { placement: 'inside' as const, x: 0, y: 0, textAnchor: 'middle' as const }
311
+
312
+ if (showCategoryPercentageLabels) {
313
+ categoryLabel = String(arc.data[config.runtime.xAxis.dataKey] ?? '')
314
+ const labelWidth = Math.max(measurePieLabelText(categoryLabel), measurePieLabelText(roundedPercentage))
315
+ labelPosition = getPieLabelPosition({
316
+ startAngle: arc.startAngle,
317
+ endAngle: arc.endAngle,
318
+ innerRadius: radius - donutThickness,
319
+ outerRadius: radius,
320
+ labelWidth,
321
+ labelHeight: ENHANCED_PIE_LABEL_HEIGHT,
322
+ isDonut: config.pieType === 'Donut'
323
+ })
324
+ }
275
325
 
276
326
  // Determine if this slice should be muted based on legend behavior
277
327
  const isHighlighted =
@@ -279,6 +329,8 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
279
329
  const shouldMute = config.legend.behavior === 'highlight' && seriesHighlight.length > 0 && !isHighlighted
280
330
  const sliceOpacity = shouldMute ? 0.3 : 1
281
331
  const textOpacity = shouldMute ? 0.3 : 1
332
+ const labelFill = labelPosition.placement === 'inside' ? textColor : '#000000'
333
+ const labelMinAngle = showCategoryPercentageLabels ? ENHANCED_PIE_LABEL_MIN_ANGLE : LEGACY_PERCENT_LABEL_MIN_ANGLE
282
334
 
283
335
  return (
284
336
  <Group key={key} className={`slice-${CSS.escape(String(key))}`}>
@@ -302,7 +354,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
302
354
  />
303
355
 
304
356
  {/* ── the percentage label */}
305
- {arc.endAngle - arc.startAngle > 0.1 && (
357
+ {arc.endAngle - arc.startAngle > labelMinAngle && !showCategoryPercentageLabels && (
306
358
  <animated.text
307
359
  transform={to([styles.startAngle, styles.endAngle], (start: number, end: number) => {
308
360
  const [x, y] = path.centroid({
@@ -321,6 +373,37 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
321
373
  {roundedPercentage}
322
374
  </animated.text>
323
375
  )}
376
+
377
+ {arc.endAngle - arc.startAngle > labelMinAngle && showCategoryPercentageLabels && (
378
+ <animated.text
379
+ className={`pie-label pie-label--${labelPosition.placement}`}
380
+ transform={to([styles.startAngle, styles.endAngle], (start: number, end: number) => {
381
+ const midAngle = start + (end - start) / 2
382
+ const innerRadius = radius - donutThickness
383
+ const outsideOffset = OUTSIDE_LABEL_OFFSET
384
+ const labelRadius =
385
+ labelPosition.placement === 'inside'
386
+ ? innerRadius + (radius - innerRadius) * 0.62
387
+ : radius + outsideOffset
388
+ const x = Math.sin(midAngle) * labelRadius
389
+ const y = -Math.cos(midAngle) * labelRadius
390
+ return `translate(${x},${y})`
391
+ })}
392
+ textAnchor={labelPosition.textAnchor}
393
+ pointerEvents='none'
394
+ fill={labelFill}
395
+ opacity={textOpacity}
396
+ style={{ fontSize: ENHANCED_PIE_LABEL_FONT_SIZE, fontFamily: 'sans-serif' }}
397
+ >
398
+ <title>{`${categoryLabel}: ${roundedPercentage}`}</title>
399
+ <tspan x={0} dy={-ENHANCED_PIE_LABEL_LINE_HEIGHT / 2}>
400
+ {categoryLabel}
401
+ </tspan>
402
+ <tspan x={0} dy={ENHANCED_PIE_LABEL_LINE_HEIGHT}>
403
+ {roundedPercentage}
404
+ </tspan>
405
+ </animated.text>
406
+ )}
324
407
  </Group>
325
408
  )
326
409
  })
@@ -334,11 +417,35 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
334
417
  }
335
418
 
336
419
  const height = config.heights.vertical
337
-
338
- const radius = Math.min(width, height) / 2
420
+ const maxPieLabelWidth = useMemo(() => {
421
+ if (!showCategoryPercentageLabels) return 0
422
+
423
+ const roundTo = Number(config.dataFormat.roundTo) || 0
424
+ const percentExample = `${(100).toFixed(roundTo)}%`
425
+
426
+ return _data.reduce((widestLabel, datum) => {
427
+ const categoryLabel = String(datum[config.runtime.xAxis.dataKey] ?? '')
428
+ return Math.max(widestLabel, measurePieLabelText(categoryLabel), measurePieLabelText(percentExample))
429
+ }, 0)
430
+ }, [_data, config.dataFormat.roundTo, config.runtime.xAxis.dataKey, showCategoryPercentageLabels])
431
+ const maxHorizontalLabelGutter = showCategoryPercentageLabels
432
+ ? Math.max(0, (width - MIN_RADIUS_WITH_LABELS * 2) / 2)
433
+ : 0
434
+ const labelGutter = showCategoryPercentageLabels
435
+ ? Math.min(Math.max(maxPieLabelWidth + LABEL_GUTTER_PADDING, MIN_LABEL_GUTTER), maxHorizontalLabelGutter)
436
+ : 0
437
+ const availableDiameter = showCategoryPercentageLabels
438
+ ? Math.min(width - labelGutter * 2, height - VERTICAL_LABEL_GUTTER * 2)
439
+ : Math.min(width, height)
440
+
441
+ const radius = showCategoryPercentageLabels
442
+ ? Math.max(0, availableDiameter / 2)
443
+ : Math.min(width, height) / 2
444
+ const svgWidth = showCategoryPercentageLabels ? width : radius * 2
445
+ const svgLeftOffset = Math.max((props.parentWidth - svgWidth) / 2, 0)
339
446
  const centerY = height / 2
340
- const centerX = props.parentWidth / 2
341
- const donutThickness = config.pieType === 'Donut' ? 75 : radius
447
+ const centerX = showCategoryPercentageLabels ? svgWidth / 2 : radius
448
+ const donutThickness = config.pieType === 'Donut' ? Math.min(75, radius * 0.65) : radius
342
449
 
343
450
  useEffect(() => {
344
451
  if (seriesHighlight.length > 0 && config.legend.behavior !== 'highlight') {
@@ -389,7 +496,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
389
496
  <>
390
497
  <ErrorBoundary component='PieChart'>
391
498
  <svg
392
- width={radius * 2}
499
+ width={svgWidth}
393
500
  height={height}
394
501
  className={getSvgClasses()}
395
502
  role='img'
@@ -400,7 +507,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
400
507
  handleChartMouseLeave()
401
508
  }}
402
509
  >
403
- <Group top={centerY} left={radius}>
510
+ <Group top={centerY} left={centerX}>
404
511
  {/* prettier-ignore */}
405
512
  <Pie
406
513
  data={filteredData || _data}
@@ -435,7 +542,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
435
542
  }) !important`}</style>
436
543
  <TooltipWithBounds
437
544
  className={'tooltip cove-visualization'}
438
- left={tooltipLeft + centerX - radius}
545
+ left={tooltipLeft + svgLeftOffset}
439
546
  top={tooltipTop}
440
547
  >
441
548
  <ul>