@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.
- package/CONFIG.md +20 -14
- package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
- package/dist/cdcchart.js +36554 -34320
- package/examples/feature/heatmap/average-age-categorical.json +1 -1
- package/examples/feature/heatmap/calendar-heatmap.json +1 -1
- package/examples/feature/heatmap/sparse-aggregation.json +1 -1
- package/examples/private/bad-y-axis.json +543 -0
- package/examples/private/bar-chart-negative-value.json +455 -0
- package/examples/private/flueview.json +22051 -0
- package/package.json +3 -11
- package/src/CdcChart.tsx +7 -6
- package/src/CdcChartComponent.tsx +71 -18
- package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
- package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
- package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
- package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
- package/src/_stories/Chart.smoke.stories.tsx +14 -1
- package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
- package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
- package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
- package/src/_stories/_mock/heatmap.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
- package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
- package/src/components/Axis/LeftAxis.tsx +3 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
- package/src/components/Axis/RightAxis.tsx +3 -0
- package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
- package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
- package/src/components/BarChart/helpers/getBarData.ts +28 -28
- package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
- package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
- package/src/components/BarChart/helpers/useBarChart.ts +4 -5
- package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
- package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
- package/src/components/BoxPlot/helpers/index.ts +4 -3
- package/src/components/Brush/BrushSelector.tsx +1 -1
- package/src/components/EditorPanel/EditorPanel.tsx +457 -165
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
- package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
- package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
- package/src/components/EditorPanel/components/panels.scss +10 -0
- package/src/components/EditorPanel/editor-panel.scss +47 -0
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
- package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +288 -11
- package/src/components/EditorPanel/useEditorPermissions.ts +46 -6
- package/src/components/Forecasting/Forecasting.tsx +1 -1
- package/src/components/HeatMap/components/HeatMap.tsx +22 -17
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
- package/src/components/HeatMap/heatmap.css +15 -1
- package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
- package/src/components/Legend/Legend.Component.tsx +56 -61
- package/src/components/Legend/Legend.Suppression.tsx +3 -3
- package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
- package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
- package/src/components/LineChart/helpers.test.ts +230 -1
- package/src/components/LineChart/helpers.ts +25 -14
- package/src/components/LineChart/index.tsx +23 -7
- package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
- package/src/components/LinearChart.tsx +51 -15
- package/src/components/PieChart/PieChart.tsx +117 -10
- package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
- package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
- package/src/components/ScatterPlot/ScatterPlot.jsx +5 -13
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
- package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
- package/src/data/initial-state.js +3 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
- package/src/helpers/categoryOrder.ts +60 -0
- package/src/helpers/colorAssignmentHelpers.ts +102 -0
- package/src/helpers/dataHelpers.ts +11 -1
- package/src/helpers/getAxisMaxOverride.ts +16 -0
- package/src/helpers/getBoxPlotConfig.ts +19 -12
- package/src/helpers/getCleanTopTickMax.ts +64 -0
- package/src/helpers/getColorScale.ts +18 -9
- package/src/helpers/getComboChartConfig.ts +0 -1
- package/src/helpers/getExcludedData.ts +0 -1
- package/src/helpers/getHasBoundarySuppression.ts +27 -0
- package/src/helpers/getMinMax.ts +25 -22
- package/src/helpers/getNewRuntime.ts +0 -2
- package/src/helpers/getPiePercent.ts +3 -3
- package/src/helpers/getYAxisDomainData.ts +38 -0
- package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
- package/src/helpers/hasSpacedInlineLabel.ts +10 -0
- package/src/helpers/isConvertLineToBarGraph.ts +2 -2
- package/src/helpers/seriesColumnSettings.ts +26 -7
- package/src/helpers/sizeHelpers.ts +0 -2
- package/src/helpers/smallMultiplesHelpers.ts +5 -0
- package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
- package/src/helpers/tests/categoryOrder.test.ts +57 -0
- package/src/helpers/tests/dataHelpers.test.ts +135 -0
- package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
- package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
- package/src/helpers/tests/getColorScale.test.ts +197 -0
- package/src/helpers/tests/getMinMax.test.ts +211 -0
- package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
- package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
- package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
- package/src/helpers/tests/seriesColumnSettings.test.ts +76 -2
- package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
- package/src/hooks/tests/useRightAxis.test.tsx +268 -0
- package/src/hooks/tests/useScales.test.tsx +302 -27
- package/src/hooks/tests/useTooltip.test.tsx +132 -0
- package/src/hooks/useRightAxis.ts +35 -25
- package/src/hooks/useScales.ts +45 -28
- package/src/hooks/useTooltip.tsx +17 -11
- package/src/store/chart.reducer.ts +0 -1
- package/src/test/CdcChart.dataTable.test.tsx +216 -0
- package/src/types/ChartConfig.ts +6 -1
- package/src/types/ChartContext.ts +3 -0
- package/src/helpers/getYAxisAutoPadding.ts +0 -55
- package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
- 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 =
|
|
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
|
|
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 {
|
|
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> = ({
|
|
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,
|
|
101
|
-
[config,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|