@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
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getPieLabelPlacement, getPieLabelPosition } from './labelPlacement'
3
+
4
+ describe('pie label placement', () => {
5
+ it('keeps labels inside regular pie slices when the slice has enough room', () => {
6
+ expect(
7
+ getPieLabelPlacement({
8
+ startAngle: 0,
9
+ endAngle: Math.PI / 2,
10
+ innerRadius: 0,
11
+ outerRadius: 120,
12
+ labelWidth: 50,
13
+ labelHeight: 28,
14
+ isDonut: false
15
+ })
16
+ ).toBe('inside')
17
+ })
18
+
19
+ it('moves regular pie labels outside when they are too wide for the slice', () => {
20
+ expect(
21
+ getPieLabelPlacement({
22
+ startAngle: 0,
23
+ endAngle: Math.PI / 8,
24
+ innerRadius: 0,
25
+ outerRadius: 120,
26
+ labelWidth: 80,
27
+ labelHeight: 28,
28
+ isDonut: false
29
+ })
30
+ ).toBe('outside')
31
+ })
32
+
33
+ it('always places donut labels outside the partition', () => {
34
+ expect(
35
+ getPieLabelPlacement({
36
+ startAngle: 0,
37
+ endAngle: Math.PI,
38
+ innerRadius: 60,
39
+ outerRadius: 120,
40
+ labelWidth: 20,
41
+ labelHeight: 20,
42
+ isDonut: true
43
+ })
44
+ ).toBe('outside')
45
+ })
46
+
47
+ it('anchors outside labels by side of the pie', () => {
48
+ const rightSide = getPieLabelPosition({
49
+ startAngle: 0,
50
+ endAngle: Math.PI / 8,
51
+ innerRadius: 0,
52
+ outerRadius: 120,
53
+ labelWidth: 80,
54
+ labelHeight: 28,
55
+ isDonut: false
56
+ })
57
+
58
+ const leftSide = getPieLabelPosition({
59
+ startAngle: Math.PI,
60
+ endAngle: Math.PI + Math.PI / 8,
61
+ innerRadius: 0,
62
+ outerRadius: 120,
63
+ labelWidth: 80,
64
+ labelHeight: 28,
65
+ isDonut: false
66
+ })
67
+
68
+ expect(rightSide.placement).toBe('outside')
69
+ expect(rightSide.textAnchor).toBe('start')
70
+ expect(leftSide.placement).toBe('outside')
71
+ expect(leftSide.textAnchor).toBe('end')
72
+ })
73
+
74
+ it('uses the same angle orientation as the arc generator', () => {
75
+ const topLabel = getPieLabelPosition({
76
+ startAngle: 0,
77
+ endAngle: Math.PI / 12,
78
+ innerRadius: 0,
79
+ outerRadius: 120,
80
+ labelWidth: 80,
81
+ labelHeight: 28,
82
+ isDonut: false
83
+ })
84
+
85
+ expect(topLabel.placement).toBe('outside')
86
+ expect(topLabel.y).toBeLessThan(0)
87
+ })
88
+ })
@@ -0,0 +1,95 @@
1
+ export type PieLabelPlacement = 'inside' | 'outside'
2
+
3
+ export type PieLabelAnchor = 'start' | 'middle' | 'end'
4
+
5
+ export type PieLabelPosition = {
6
+ placement: PieLabelPlacement
7
+ x: number
8
+ y: number
9
+ textAnchor: PieLabelAnchor
10
+ }
11
+
12
+ type PieLabelPlacementOptions = {
13
+ startAngle: number
14
+ endAngle: number
15
+ innerRadius: number
16
+ outerRadius: number
17
+ labelWidth: number
18
+ labelHeight: number
19
+ isDonut: boolean
20
+ insidePadding?: number
21
+ }
22
+
23
+ type PieLabelPositionOptions = PieLabelPlacementOptions & {
24
+ outsideOffset?: number
25
+ }
26
+
27
+ const DEFAULT_INSIDE_PADDING = 12
28
+ const DEFAULT_OUTSIDE_OFFSET = 22
29
+
30
+ const getMidAngle = (startAngle: number, endAngle: number) => startAngle + (endAngle - startAngle) / 2
31
+ const getArcPoint = (angle: number, radius: number) => ({
32
+ x: Math.sin(angle) * radius,
33
+ y: -Math.cos(angle) * radius
34
+ })
35
+
36
+ export const getPieLabelPlacement = ({
37
+ startAngle,
38
+ endAngle,
39
+ innerRadius,
40
+ outerRadius,
41
+ labelWidth,
42
+ labelHeight,
43
+ isDonut,
44
+ insidePadding = DEFAULT_INSIDE_PADDING
45
+ }: PieLabelPlacementOptions): PieLabelPlacement => {
46
+ if (isDonut) return 'outside'
47
+
48
+ const angle = Math.abs(endAngle - startAngle)
49
+ const labelRadius = innerRadius + (outerRadius - innerRadius) * 0.62
50
+ const availableChord = 2 * labelRadius * Math.sin(angle / 2)
51
+ const availableRadialDepth = outerRadius - innerRadius
52
+
53
+ const hasEnoughArcWidth = labelWidth + insidePadding <= availableChord
54
+ const hasEnoughRadialDepth = labelHeight + insidePadding <= availableRadialDepth
55
+
56
+ return hasEnoughArcWidth && hasEnoughRadialDepth ? 'inside' : 'outside'
57
+ }
58
+
59
+ export const getPieLabelPosition = ({
60
+ startAngle,
61
+ endAngle,
62
+ innerRadius,
63
+ outerRadius,
64
+ labelWidth,
65
+ labelHeight,
66
+ isDonut,
67
+ insidePadding,
68
+ outsideOffset = DEFAULT_OUTSIDE_OFFSET
69
+ }: PieLabelPositionOptions): PieLabelPosition => {
70
+ const placement = getPieLabelPlacement({
71
+ startAngle,
72
+ endAngle,
73
+ innerRadius,
74
+ outerRadius,
75
+ labelWidth,
76
+ labelHeight,
77
+ isDonut,
78
+ insidePadding
79
+ })
80
+ const midAngle = getMidAngle(startAngle, endAngle)
81
+ const labelRadius =
82
+ placement === 'inside' ? innerRadius + (outerRadius - innerRadius) * 0.62 : outerRadius + outsideOffset
83
+ const { x, y } = getArcPoint(midAngle, labelRadius)
84
+
85
+ if (placement === 'inside') {
86
+ return { placement, x, y, textAnchor: 'middle' }
87
+ }
88
+
89
+ return {
90
+ placement,
91
+ x,
92
+ y,
93
+ textAnchor: x < 0 ? 'end' : 'start'
94
+ }
95
+ }
@@ -5,8 +5,9 @@ import { formatNumber as formatColNumber } from '@cdc/core/helpers/cove/number'
5
5
  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
+ import { getSeriesColumnFormattingParams } from '../../helpers/seriesColumnSettings'
8
9
 
9
- const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
10
+ const ScatterPlot = ({ xScale, yScale, yAxisWidth, getXAxisData }) => {
10
11
  const {
11
12
  transformedData: data,
12
13
  config,
@@ -25,16 +26,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
25
26
  // tooltips for additional columns
26
27
  const additionalColumns = Object.entries(config.columns)
27
28
  .filter(([_, value]) => value.tooltips)
28
- .map(([_, value]) => [
29
- value.label || value.name,
30
- value.name,
31
- {
32
- addColPrefix: value.prefix,
33
- addColSuffix: value.suffix,
34
- addColRoundTo: value.roundToPlace,
35
- addColCommas: value.commas
36
- }
37
- ])
29
+ .map(([_, value]) => [value.label || value.name, value.name, getSeriesColumnFormattingParams(value)])
38
30
  const handleTooltip = (item, s, dataIndex) => `<div>
39
31
  ${buildSeriesTooltipListHtml({
40
32
  config,
@@ -61,7 +53,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
61
53
  return config.runtime.seriesKeys.map((s, index) => {
62
54
  const transparentArea = config.legend.behavior === 'highlight' && seriesHighlight.length > 0 && seriesHighlight.indexOf(s) === -1
63
55
  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)
56
+ const seriesColor = colorScale(config.runtime.seriesLabels?.[s] || s)
65
57
 
66
58
  let pointStyles = {
67
59
  filter: 'unset',
@@ -78,7 +70,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
78
70
  <circle
79
71
  key={`${dataIndex}-${index}`}
80
72
  r={circleRadii}
81
- cx={xScale(item[config.xAxis.dataKey])}
73
+ cx={xScale(getXAxisData(item))}
82
74
  cy={yScale(item[s])}
83
75
  fill={displayArea ? seriesColor : 'transparent'}
84
76
  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,
@@ -143,6 +144,8 @@ const createInitialState = () => {
143
144
  padding: 5,
144
145
  showYearsOnce: false,
145
146
  sortByRecentDate: false,
147
+ categoryOrder: [],
148
+ categoryOrderType: 'data',
146
149
  brushActive: false,
147
150
  brushDefaultRecentDateCount: undefined,
148
151
  brushDynamicYAxis: false,
@@ -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
@@ -0,0 +1,60 @@
1
+ import { ChartConfig } from '../types/ChartConfig'
2
+
3
+ type DataRow = Record<string, any>
4
+
5
+ export const isCustomCategoryOrderActive = (config: Pick<ChartConfig, 'xAxis'>) => {
6
+ return (
7
+ config.xAxis?.type === 'categorical' &&
8
+ config.xAxis?.categoryOrderType === 'custom' &&
9
+ Array.isArray(config.xAxis?.categoryOrder) &&
10
+ config.xAxis.categoryOrder.length > 0 &&
11
+ Boolean(config.xAxis?.dataKey)
12
+ )
13
+ }
14
+
15
+ export const getCategoryValues = (data: DataRow[] = [], dataKey?: string): string[] => {
16
+ if (!dataKey || !Array.isArray(data)) return []
17
+
18
+ const values: string[] = []
19
+ for (const row of data) {
20
+ const value = String(row?.[dataKey] ?? '')
21
+ if (value !== '') values.push(value)
22
+ }
23
+
24
+ return Array.from(new Set(values))
25
+ }
26
+
27
+ export const getOrderedCategoryValues = (
28
+ data: DataRow[] = [],
29
+ dataKey?: string,
30
+ categoryOrder: string[] = []
31
+ ): string[] => {
32
+ const values = getCategoryValues(data, dataKey)
33
+ if (!categoryOrder?.length) return values
34
+
35
+ const configuredValues = categoryOrder.map(String)
36
+ const configuredValueSet = new Set(configuredValues)
37
+ const valueSet = new Set(values)
38
+ const extras = values.filter(value => !configuredValueSet.has(value))
39
+
40
+ return [...configuredValues.filter(value => valueSet.has(value)), ...extras]
41
+ }
42
+
43
+ export const sortByCategoryOrder = (data: DataRow[] = [], config: Pick<ChartConfig, 'xAxis'>): DataRow[] => {
44
+ if (!Array.isArray(data) || !isCustomCategoryOrderActive(config)) return data
45
+
46
+ const dataKey = config.xAxis.dataKey
47
+ const categoryOrder = Array.from(new Set((config.xAxis.categoryOrder || []).map(String)))
48
+ const indexByValue = new Map(categoryOrder.map((value, index) => [value, index]))
49
+ return data
50
+ .map((row, originalIndex) => ({ row, originalIndex }))
51
+ .sort((a, b) => {
52
+ const indexA = indexByValue.get(String(a.row?.[dataKey] ?? ''))
53
+ const indexB = indexByValue.get(String(b.row?.[dataKey] ?? ''))
54
+ const rankA = indexA ?? Number.MAX_SAFE_INTEGER
55
+ const rankB = indexB ?? Number.MAX_SAFE_INTEGER
56
+
57
+ return rankA === rankB ? a.originalIndex - b.originalIndex : rankA - rankB
58
+ })
59
+ .map(({ row }) => row)
60
+ }