@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
@@ -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,28 @@ 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 = Array.isArray(config.yAxisDomainData) && config.yAxisDomainData.length > 0
148
+ ? getExcludedData(config, config.yAxisDomainData)
149
+ : isStableYAxisDomain && Array.isArray(excludedData)
150
+ ? excludedData
151
+ : tableData
152
+ const suppressionDomainData = getYAxisDomainData({
153
+ config,
154
+ data: tableData,
155
+ tableData,
156
+ fullEligibleDomainData: rawEligibleYAxisDomainData
157
+ })
158
+ const hasBoundarySuppression = getHasBoundarySuppression({
159
+ rows: suppressionDomainData,
160
+ config
161
+ })
136
162
  const { minValue, maxValue, existPositiveValue, isAllLine } = useReduceData(config, dataForMinMax)
137
163
 
138
164
  const { visSupportsSmallMultiples } = useEditorPermissions()
@@ -177,7 +203,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
177
203
  const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
178
204
  const isForestPlot = visualizationType === 'Forest Plot'
179
205
  const isDateTime = config.xAxis.type === 'date-time'
180
- const yAxisAutoPaddingMode = getYAxisAutoPaddingMode(config)
206
+ const usesSpacedInlineLabel = hasSpacedInlineLabel(config)
181
207
  const tickLabelFontSize = isMobileFontViewport(vizViewport) ? TICK_LABEL_FONT_SIZE_SMALL : TICK_LABEL_FONT_SIZE
182
208
  const axisLabelFontSize = getAxisLabelFontSize(vizViewport)
183
209
  const GET_TEXT_WIDTH_FONT = `normal ${tickLabelFontSize}px Nunito, sans-serif`
@@ -255,14 +281,14 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
255
281
  return () => observer.disconnect()
256
282
  }, [axisBottomRef.current])
257
283
 
258
- const { yScaleRight, hasRightAxis } = useRightAxis({ config, yMax, data })
284
+ const { yScaleRight, hasRightAxis, rightTickValues } = useRightAxis({ config, yMax, data: dataForMinMax })
259
285
 
260
286
  // State for computed left-axis width - shared across all linear-chart types.
261
287
  const [currentYAxisWidth, setCurrentYAxisWidth] = useState<number>(DEFAULT_LEFT_Y_AXIS_WIDTH)
262
288
  const yAxisWidth = currentYAxisWidth
263
289
 
264
290
  // Chart width calculation using the current y-axis width
265
- const xMax = parentWidth - yAxisWidth - (hasRightAxis ? config.yAxis.rightAxisSize : 0)
291
+ const xMax = Math.max(0, parentWidth - yAxisWidth - (hasRightAxis ? config.yAxis.rightAxisSize : 0))
266
292
 
267
293
  // Stabilize brush container dimensions when brushDynamicYAxis is enabled.
268
294
  // Without this, y-axis rescaling on each brush change creates a feedback loop:
@@ -289,10 +315,13 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
289
315
  xScaleAnnotation,
290
316
  yScaleAnnotation,
291
317
  min,
292
- max
318
+ max,
319
+ yTickValues
293
320
  } = useScales({
294
321
  data,
295
- tableData,
322
+ tableData: dataForMinMax,
323
+ yAxisDomainData: dataForMinMax,
324
+ hasBoundarySuppression,
296
325
  config,
297
326
  minValue,
298
327
  maxValue,
@@ -301,9 +330,10 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
301
330
  xAxisDataMapped,
302
331
  yMax,
303
332
  xMax,
304
- yAxisAutoPaddingMode,
333
+ hasSpacedInlineLabel: usesSpacedInlineLabel,
305
334
  currentViewport: vizViewport
306
335
  })
336
+ const effectiveYTickValues = sharedYAxisTickValues ?? yTickValues
307
337
 
308
338
  // Consolidated tick formatters
309
339
  const { handleLeftTickFormatting, handleBottomTickFormatting } = useTickFormatters({
@@ -358,6 +388,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
358
388
  data,
359
389
  yScale,
360
390
  numTicks: handleNumTicks,
391
+ tickValues: effectiveYTickValues,
361
392
  parentWidth,
362
393
  tickLabelFont: GET_TEXT_WIDTH_FONT,
363
394
  axisLabelFontSize,
@@ -369,6 +400,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
369
400
  config,
370
401
  data,
371
402
  yScale,
403
+ effectiveYTickValues,
372
404
  handleNumTicks,
373
405
  parentWidth,
374
406
  isHorizontal,
@@ -596,6 +628,8 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
596
628
  <SmallMultiples
597
629
  config={config}
598
630
  data={data}
631
+ yAxisDomainData={dataForMinMax}
632
+ hasBoundarySuppression={hasBoundarySuppression}
599
633
  svgRef={svgRef}
600
634
  parentWidth={parentWidth}
601
635
  parentHeight={parentHeight}
@@ -617,9 +651,8 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
617
651
  onMouseMove={onMouseMove}
618
652
  width={parentWidth}
619
653
  height={isNoDataAvailable ? 1 : calculatedSvgHeight ?? parentHeight}
620
- className={`linear ${config.animate ? 'animated' : ''} ${animatedChart && config.animate ? 'animate' : ''} ${
621
- debugSvg && 'debug'
622
- } ${isDraggingAnnotation && 'dragging-annotation'}`}
654
+ className={`linear ${config.animate ? 'animated' : ''} ${animatedChart && config.animate ? 'animate' : ''} ${debugSvg && 'debug'
655
+ } ${isDraggingAnnotation && 'dragging-annotation'}`}
623
656
  role='img'
624
657
  aria-label={a11y}
625
658
  style={{ overflow: 'visible' }}
@@ -642,6 +675,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
642
675
  xMax={xMax}
643
676
  yAxisWidth={yAxisWidth}
644
677
  numTicks={handleNumTicks}
678
+ tickValues={effectiveYTickValues}
645
679
  axisLabelFontSize={axisLabelFontSize}
646
680
  />
647
681
  )}
@@ -838,6 +872,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
838
872
  xMax={xMax}
839
873
  yAxisWidth={yAxisWidth}
840
874
  numTicks={handleNumTicks}
875
+ tickValues={effectiveYTickValues}
841
876
  tickLabelFontSize={tickLabelFontSize}
842
877
  axisLabelFontSize={axisLabelFontSize}
843
878
  handleLeftTickFormatting={handleLeftTickFormatting}
@@ -863,6 +898,7 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
863
898
  yMax={yMax}
864
899
  xMax={xMax}
865
900
  yAxisWidth={yAxisWidth}
901
+ tickValues={rightTickValues}
866
902
  tickLabelFontSize={tickLabelFontSize}
867
903
  axisLabelFontSize={axisLabelFontSize}
868
904
  />
@@ -914,9 +950,8 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
914
950
  tooltipData.dataXPosition &&
915
951
  !config.tooltips.singleSeries && (
916
952
  <>
917
- <style>{`.tooltip {background-color: rgba(255,255,255, ${
918
- config.tooltips.opacity / 100
919
- }) !important;`}</style>
953
+ <style>{`.tooltip {background-color: rgba(255,255,255, ${config.tooltips.opacity / 100
954
+ }) !important;`}</style>
920
955
  <style>{`.tooltip {max-width:300px} !important; word-wrap: break-word; `}</style>
921
956
  <TooltipWithBounds
922
957
  ref={tooltipRef}
@@ -6,7 +6,7 @@ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
6
6
  import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
7
7
  import { buildSeriesTooltipListHtml } from '../../helpers/tooltipHelpers'
8
8
 
9
- const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
9
+ const ScatterPlot = ({ xScale, yScale, yAxisWidth, getXAxisData }) => {
10
10
  const {
11
11
  transformedData: data,
12
12
  config,
@@ -61,7 +61,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
61
61
  return config.runtime.seriesKeys.map((s, index) => {
62
62
  const transparentArea = config.legend.behavior === 'highlight' && seriesHighlight.length > 0 && seriesHighlight.indexOf(s) === -1
63
63
  const displayArea = config.legend.behavior === 'highlight' || seriesHighlight.length === 0 || seriesHighlight.indexOf(s) !== -1
64
- const seriesColor = config?.general?.palette?.customColors ? config.general.palette.customColors[index] : colorScale(config.runtime.seriesLabels?.[s] || s)
64
+ const seriesColor = colorScale(config.runtime.seriesLabels?.[s] || s)
65
65
 
66
66
  let pointStyles = {
67
67
  filter: 'unset',
@@ -78,7 +78,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
78
78
  <circle
79
79
  key={`${dataIndex}-${index}`}
80
80
  r={circleRadii}
81
- cx={xScale(item[config.xAxis.dataKey])}
81
+ cx={xScale(getXAxisData(item))}
82
82
  cy={yScale(item[s])}
83
83
  fill={displayArea ? seriesColor : 'transparent'}
84
84
  fillOpacity={transparentArea ? 0.25 : 1}
@@ -23,6 +23,7 @@ interface SmallMultipleTileProps {
23
23
  parentHeight?: number
24
24
  globalYAxisMax?: number
25
25
  globalYAxisMin?: number
26
+ globalYAxisTickValues?: number[]
26
27
  isFirstInRow?: boolean
27
28
  onHeightChange?: (tileKey: string, height: number) => void
28
29
  onChartRef?: (ref: any) => void
@@ -43,6 +44,7 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
43
44
  parentWidth,
44
45
  globalYAxisMax,
45
46
  globalYAxisMin,
47
+ globalYAxisTickValues,
46
48
  isFirstInRow,
47
49
  onHeightChange,
48
50
  onChartRef,
@@ -152,6 +154,7 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
152
154
  tableData: tileData, // Override with tile-specific filtered data (important for tooltip data lookup)
153
155
  parentRef: tileParentRef, // Override with tile-specific parentRef
154
156
  updateConfig: () => {}, // Prevent tile hooks from modifying global config
157
+ yAxisTickValues: globalYAxisTickValues,
155
158
  ...(customColorScale && { colorScale: customColorScale }) // Override colorScale if provided
156
159
  }
157
160
 
@@ -3,7 +3,7 @@ import SmallMultipleTile from './SmallMultipleTile'
3
3
  import ConfigContext from '../../ConfigContext'
4
4
  import useReduceData from '../../hooks/useReduceData'
5
5
  import useScales from '../../hooks/useScales'
6
- import { getYAxisAutoPaddingMode } from '../../helpers/getYAxisAutoPaddingMode'
6
+ import { hasSpacedInlineLabel } from '../../helpers/hasSpacedInlineLabel'
7
7
  import { createCombinedDataForYAxis, applyTileOrder, createTileColorScale } from '../../helpers/smallMultiplesHelpers'
8
8
  import { isMobileSmallMultiplesViewport } from '@cdc/core/helpers/viewports'
9
9
  import './SmallMultiples.css'
@@ -12,6 +12,8 @@ import { ChartConfig } from '../../types/ChartConfig'
12
12
  interface SmallMultiplesProps {
13
13
  config: ChartConfig
14
14
  data: object[]
15
+ yAxisDomainData?: object[]
16
+ hasBoundarySuppression?: boolean
15
17
  svgRef?: React.RefObject<SVGAElement>
16
18
  parentWidth?: number
17
19
  parentHeight?: number
@@ -34,12 +36,21 @@ type TileHeaderRows = Array<Array<HTMLDivElement>>
34
36
 
35
37
  type TileHeaderEntries = Array<[string, HTMLDivElement]>
36
38
 
37
- const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, parentWidth, parentHeight }) => {
39
+ const SmallMultiples: React.FC<SmallMultiplesProps> = ({
40
+ config,
41
+ data,
42
+ yAxisDomainData,
43
+ hasBoundarySuppression = false,
44
+ svgRef,
45
+ parentWidth,
46
+ parentHeight
47
+ }) => {
38
48
  const { currentViewport, colorScale, parentRef } = useContext(ConfigContext)
39
49
  const { mode, tileColumn, tilesPerRowDesktop, tilesPerRowMobile } = config.smallMultiples || {}
40
50
 
41
51
  const isMobile = isMobileSmallMultiplesViewport(currentViewport)
42
52
  const tilesPerRow = isMobile ? tilesPerRowMobile || 1 : tilesPerRowDesktop || 3
53
+ const dataForSharedYAxis = Array.isArray(yAxisDomainData) && yAxisDomainData.length > 0 ? yAxisDomainData : data
43
54
 
44
55
  // Figure out what objects to iterate over based on mode - memoized to prevent recalculation
45
56
  const tileItems = useMemo<Array<TileItem>>(() => {
@@ -80,6 +91,22 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
80
91
  config.smallMultiples?.tileTitles
81
92
  ])
82
93
 
94
+ const sharedYAxisTileItems = useMemo<Array<TileItem>>(
95
+ () =>
96
+ mode === 'by-column'
97
+ ? Array.from(new Set(dataForSharedYAxis.map(row => row[tileColumn])))
98
+ .filter(val => val != null)
99
+ .sort()
100
+ .map(value => ({
101
+ key: value,
102
+ mode: 'by-column' as const,
103
+ tileValue: value,
104
+ tileColumn: tileColumn
105
+ }))
106
+ : tileItems,
107
+ [mode, dataForSharedYAxis, tileColumn, tileItems]
108
+ )
109
+
83
110
  // Calculate the grid styling based on tiles per row
84
111
  const gridGap = isMobile ? '1rem' : '2rem'
85
112
  const gridStyle = {
@@ -97,8 +124,8 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
97
124
 
98
125
  // Create combined data and config for consistent Y-axis calculation
99
126
  const combinedDataForYAxis = useMemo(
100
- () => createCombinedDataForYAxis(config, data, tileItems),
101
- [config, data, tileItems]
127
+ () => createCombinedDataForYAxis(config, dataForSharedYAxis, sharedYAxisTileItems),
128
+ [config, dataForSharedYAxis, sharedYAxisTileItems]
102
129
  )
103
130
 
104
131
  const { minValue, maxValue, existPositiveValue, isAllLine } = useReduceData(
@@ -106,12 +133,13 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
106
133
  combinedDataForYAxis.data
107
134
  )
108
135
 
109
- const yAxisAutoPaddingMode = getYAxisAutoPaddingMode(config)
136
+ const usesSpacedInlineLabel = hasSpacedInlineLabel(config)
110
137
 
111
- const { min, max } = useScales({
138
+ const { min, max, yTickValues } = useScales({
112
139
  config: combinedDataForYAxis.config,
113
140
  data: combinedDataForYAxis.data,
114
141
  tableData: combinedDataForYAxis.data,
142
+ hasBoundarySuppression,
115
143
  minValue,
116
144
  maxValue,
117
145
  existPositiveValue,
@@ -119,7 +147,7 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
119
147
  xAxisDataMapped: [],
120
148
  xMax: parentWidth,
121
149
  yMax: parentHeight,
122
- yAxisAutoPaddingMode,
150
+ hasSpacedInlineLabel: usesSpacedInlineLabel,
123
151
  currentViewport
124
152
  })
125
153
 
@@ -129,8 +157,8 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
129
157
  if (typeof min !== 'number' || typeof max !== 'number') return null
130
158
  if (combinedDataForYAxis.data.length === 0) return null
131
159
 
132
- return { min, max }
133
- }, [config.smallMultiples?.independentYAxis, min, max, combinedDataForYAxis.data.length])
160
+ return { min, max, tickValues: yTickValues }
161
+ }, [config.smallMultiples?.independentYAxis, min, max, yTickValues, combinedDataForYAxis.data.length])
134
162
 
135
163
  const numberOfRows = useMemo(() => Math.ceil(tileItems.length / tilesPerRow), [tileItems.length, tilesPerRow])
136
164
 
@@ -257,6 +285,7 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
257
285
  parentHeight={parentHeight}
258
286
  globalYAxisMax={globalYAxisValues?.max}
259
287
  globalYAxisMin={globalYAxisValues?.min}
288
+ globalYAxisTickValues={globalYAxisValues?.tickValues}
260
289
  isFirstInRow={index % tilesPerRow === 0}
261
290
  onHeightChange={handleTileHeightChange}
262
291
  onChartRef={ref => {
@@ -0,0 +1,170 @@
1
+ import React from 'react'
2
+ import { render } from '@testing-library/react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import ConfigContext from '../../../ConfigContext'
5
+ import SmallMultiples from '../SmallMultiples'
6
+ import { createMockChartContext, createMockConfig } from '../../LinearChart/tests/mockConfigContext'
7
+
8
+ const tileProps = vi.hoisted(() => [] as any[])
9
+ const mockUseReduceData = vi.hoisted(() => vi.fn())
10
+ const mockUseScales = vi.hoisted(() => vi.fn())
11
+
12
+ vi.mock('../SmallMultipleTile', () => ({
13
+ default: props => {
14
+ tileProps.push(props)
15
+ return <div data-testid={`tile-${props.tileKey}`} />
16
+ }
17
+ }))
18
+
19
+ vi.mock('../../../hooks/useReduceData', () => ({
20
+ default: (...args: any[]) => mockUseReduceData(...args)
21
+ }))
22
+
23
+ vi.mock('../../../hooks/useScales', () => ({
24
+ default: (...args: any[]) => mockUseScales(...args)
25
+ }))
26
+
27
+ describe('SmallMultiples', () => {
28
+ const createSmallMultiplesConfig = () =>
29
+ createMockConfig({
30
+ smallMultiples: {
31
+ mode: 'by-column',
32
+ tileColumn: 'Sex',
33
+ independentYAxis: false,
34
+ tilesPerRowDesktop: 2,
35
+ tilesPerRowMobile: 1
36
+ },
37
+ series: [{ dataKey: 'Cases', type: 'Bar' }] as any,
38
+ runtime: {
39
+ ...createMockConfig().runtime,
40
+ series: [{ dataKey: 'Cases', type: 'Bar' }],
41
+ seriesKeys: ['Cases'],
42
+ seriesLabelsAll: ['Cases']
43
+ } as any
44
+ })
45
+
46
+ beforeEach(() => {
47
+ tileProps.length = 0
48
+ mockUseReduceData.mockReset()
49
+ mockUseScales.mockReset()
50
+ mockUseReduceData.mockReturnValue({
51
+ minValue: 0,
52
+ maxValue: 100,
53
+ existPositiveValue: true,
54
+ isAllLine: false
55
+ })
56
+ mockUseScales.mockReturnValue({
57
+ min: 0,
58
+ max: 100,
59
+ yTickValues: [0, 50, 100]
60
+ })
61
+ })
62
+
63
+ it('uses stable domain data for the shared Y-axis while rendering filtered tile data', () => {
64
+ const filteredRows = [
65
+ { Sex: 'Male', Year: '2019', Cases: 10 },
66
+ { Sex: 'Female', Year: '2019', Cases: 20 }
67
+ ]
68
+ const stableDomainRows = [
69
+ ...filteredRows,
70
+ { Sex: 'Male', Year: '2020', Cases: 100 },
71
+ { Sex: 'Female', Year: '2020', Cases: 80 }
72
+ ]
73
+ const config = createSmallMultiplesConfig()
74
+ const context = createMockChartContext(config, {
75
+ colorScale: vi.fn(),
76
+ parentRef: { current: document.createElement('div') }
77
+ } as any)
78
+
79
+ render(
80
+ <ConfigContext.Provider value={context}>
81
+ <SmallMultiples
82
+ config={config}
83
+ data={filteredRows}
84
+ yAxisDomainData={stableDomainRows}
85
+ parentWidth={600}
86
+ parentHeight={300}
87
+ />
88
+ </ConfigContext.Provider>
89
+ )
90
+
91
+ const expectedSharedAxisRows = [
92
+ { Sex: 'Female', Year: '2019', Cases: 20 },
93
+ { Sex: 'Female', Year: '2020', Cases: 80 },
94
+ { Sex: 'Male', Year: '2019', Cases: 10 },
95
+ { Sex: 'Male', Year: '2020', Cases: 100 }
96
+ ]
97
+
98
+ expect(mockUseReduceData.mock.calls[0][1]).toEqual(expectedSharedAxisRows)
99
+ expect(mockUseScales.mock.calls[0][0].data).toEqual(expectedSharedAxisRows)
100
+ expect(mockUseScales.mock.calls[0][0].hasBoundarySuppression).toBe(false)
101
+ expect(tileProps).toHaveLength(2)
102
+ expect(tileProps[0].data).toBe(filteredRows)
103
+ expect(tileProps[0].globalYAxisMax).toBe(100)
104
+ expect(tileProps[0].globalYAxisTickValues).toEqual([0, 50, 100])
105
+ })
106
+
107
+ it('includes filtered-out tile values when calculating a stable shared Y-axis', () => {
108
+ const filteredRows = [{ Sex: 'Male', Year: '2019', Cases: 10 }]
109
+ const stableDomainRows = [...filteredRows, { Sex: 'Female', Year: '2019', Cases: 200 }]
110
+ const config = createSmallMultiplesConfig()
111
+ const context = createMockChartContext(config, {
112
+ colorScale: vi.fn(),
113
+ parentRef: { current: document.createElement('div') }
114
+ } as any)
115
+
116
+ render(
117
+ <ConfigContext.Provider value={context}>
118
+ <SmallMultiples
119
+ config={config}
120
+ data={filteredRows}
121
+ yAxisDomainData={stableDomainRows}
122
+ parentWidth={600}
123
+ parentHeight={300}
124
+ />
125
+ </ConfigContext.Provider>
126
+ )
127
+
128
+ expect(mockUseReduceData.mock.calls[0][1]).toEqual([
129
+ { Sex: 'Female', Year: '2019', Cases: 200 },
130
+ { Sex: 'Male', Year: '2019', Cases: 10 }
131
+ ])
132
+ expect(mockUseScales.mock.calls[0][0].data).toEqual([
133
+ { Sex: 'Female', Year: '2019', Cases: 200 },
134
+ { Sex: 'Male', Year: '2019', Cases: 10 }
135
+ ])
136
+ expect(mockUseScales.mock.calls[0][0].hasBoundarySuppression).toBe(false)
137
+ expect(tileProps).toHaveLength(1)
138
+ expect(tileProps[0].tileValue).toBe('Male')
139
+ expect(tileProps[0].data).toBe(filteredRows)
140
+ })
141
+
142
+ it('passes hasBoundarySuppression from shared stable-domain boundary rows', () => {
143
+ const filteredRows = [{ Sex: 'Male', Year: '2019', Cases: 10 }]
144
+ const stableDomainRows = [
145
+ { Sex: 'Female', Year: '2018', Cases: 'ABC' },
146
+ { Sex: 'Male', Year: '2019', Cases: 10 }
147
+ ]
148
+ const config = createSmallMultiplesConfig()
149
+ config.preliminaryData = [{ type: 'suppression', style: 'Dashed Small', column: '', value: 'ABC' }] as any
150
+ const context = createMockChartContext(config, {
151
+ colorScale: vi.fn(),
152
+ parentRef: { current: document.createElement('div') }
153
+ } as any)
154
+
155
+ render(
156
+ <ConfigContext.Provider value={context}>
157
+ <SmallMultiples
158
+ config={config}
159
+ data={filteredRows}
160
+ yAxisDomainData={stableDomainRows as any}
161
+ hasBoundarySuppression={true}
162
+ parentWidth={600}
163
+ parentHeight={300}
164
+ />
165
+ </ConfigContext.Provider>
166
+ )
167
+
168
+ expect(mockUseScales.mock.calls[0][0].hasBoundarySuppression).toBe(true)
169
+ })
170
+ })
@@ -78,6 +78,7 @@ const createInitialState = () => {
78
78
  titlePlacement: 'top',
79
79
  scalePadding: 10,
80
80
  tickRotation: 0,
81
+ autoMaxStrategy: 'clean-top-tick',
81
82
  anchors: [],
82
83
  shoMissingDataLabel: true,
83
84
  showMissingDataLine: true,
@@ -11,6 +11,7 @@ type CalculateLeftYAxisWidthProps = {
11
11
  data: any[]
12
12
  yScale: any
13
13
  numTicks: number
14
+ tickValues?: number[]
14
15
  parentWidth: number
15
16
  tickLabelFont: string
16
17
  axisLabelFontSize: number
@@ -28,7 +29,11 @@ type TickValueProps = Omit<
28
29
  'parentWidth' | 'tickLabelFont' | 'axisLabelFontSize' | 'handleLeftTickFormatting'
29
30
  >
30
31
 
31
- const getTickValues = ({ config, data, yScale, numTicks }: TickValueProps) => {
32
+ const getTickValues = ({ config, data, yScale, numTicks, tickValues }: TickValueProps) => {
33
+ if (tickValues) {
34
+ return tickValues
35
+ }
36
+
32
37
  if (config.visualizationType === 'Forest Plot') {
33
38
  return data.map((_, index) => index)
34
39
  }
@@ -142,6 +147,7 @@ export const calculateLeftYAxisWidth = ({
142
147
  data,
143
148
  yScale,
144
149
  numTicks,
150
+ tickValues,
145
151
  parentWidth,
146
152
  tickLabelFont,
147
153
  axisLabelFontSize,
@@ -153,10 +159,10 @@ export const calculateLeftYAxisWidth = ({
153
159
  return getHorizonChartWidth({ config, parentWidth, tickLabelFont, axisLabelFontSize })
154
160
  }
155
161
 
156
- const tickValues = getTickValues({ config, data, yScale, numTicks })
162
+ const measuredTickValues = getTickValues({ config, data, yScale, numTicks, tickValues })
157
163
  const { hideAxis, inlineLabel, labelsAboveGridlines } = config.yAxis
158
164
  const formattedTicks = formatMeasuredTickLabels({
159
- tickValues,
165
+ tickValues: measuredTickValues,
160
166
  handleLeftTickFormatting,
161
167
  inlineLabel,
162
168
  labelsAboveGridlines