@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.
- package/CONFIG.md +12 -8
- package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
- package/dist/cdcchart.js +34093 -32767
- 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/package.json +3 -3
- package/src/CdcChart.tsx +5 -4
- package/src/CdcChartComponent.tsx +32 -9
- 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/ChartAnnotation.smoke.stories.tsx +10 -0
- 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/EditorPanel/EditorPanel.tsx +367 -136
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
- 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/useEditorPermissions.test.tsx +256 -11
- package/src/components/EditorPanel/useEditorPermissions.ts +41 -5
- 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 +2 -2
- 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 +26 -13
- package/src/components/LineChart/index.tsx +21 -4
- package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
- package/src/components/LinearChart.tsx +50 -15
- package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
- 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 +1 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
- package/src/helpers/colorAssignmentHelpers.ts +102 -0
- package/src/helpers/dataHelpers.ts +11 -1
- package/src/helpers/getAxisMaxOverride.ts +16 -0
- package/src/helpers/getCleanTopTickMax.ts +64 -0
- package/src/helpers/getColorScale.ts +18 -9
- package/src/helpers/getHasBoundarySuppression.ts +26 -0
- package/src/helpers/getMinMax.ts +24 -22
- package/src/helpers/getYAxisDomainData.ts +38 -0
- package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
- package/src/helpers/hasSpacedInlineLabel.ts +10 -0
- package/src/helpers/seriesColumnSettings.ts +5 -4
- package/src/helpers/smallMultiplesHelpers.ts +5 -0
- package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -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 +16 -2
- package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
- package/src/hooks/tests/useRightAxis.test.tsx +127 -0
- package/src/hooks/tests/useScales.test.tsx +307 -29
- package/src/hooks/useRightAxis.ts +30 -13
- package/src/hooks/useScales.ts +45 -28
- package/src/hooks/useTooltip.tsx +3 -1
- package/src/test/CdcChart.dataTable.test.tsx +122 -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,102 @@
|
|
|
1
|
+
import { type ChartConfig, type VisualizationType } from '../types/ChartConfig'
|
|
2
|
+
|
|
3
|
+
export type SeriesColorAssignment = {
|
|
4
|
+
key: string
|
|
5
|
+
color: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SeriesColorAssignmentItem = {
|
|
9
|
+
key: string
|
|
10
|
+
label: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const SERIES_COLOR_ASSIGNMENT_TYPES: VisualizationType[] = ['Line', 'Bar', 'Combo', 'Area Chart', 'Scatter Plot']
|
|
14
|
+
|
|
15
|
+
export const hasDynamicCategorySeries = (config: ChartConfig): boolean =>
|
|
16
|
+
Array.isArray(config.series) && config.series.some(series => Boolean(series?.dynamicCategory))
|
|
17
|
+
|
|
18
|
+
export const isColorByCategoryBarChart = (config: ChartConfig): boolean =>
|
|
19
|
+
config.visualizationType === 'Bar' &&
|
|
20
|
+
config.visualizationSubType === 'regular' &&
|
|
21
|
+
Boolean(config.legend?.colorCode) &&
|
|
22
|
+
(config.series?.length || 0) === 1
|
|
23
|
+
|
|
24
|
+
export const supportsSeriesColorAssignments = (config: ChartConfig): boolean =>
|
|
25
|
+
SERIES_COLOR_ASSIGNMENT_TYPES.includes(config.visualizationType) &&
|
|
26
|
+
!hasDynamicCategorySeries(config) &&
|
|
27
|
+
!isColorByCategoryBarChart(config)
|
|
28
|
+
|
|
29
|
+
export const isSeriesColorAssignmentModeByValue = (config: ChartConfig): boolean =>
|
|
30
|
+
config.general?.palette?.colorAssignmentMode === 'by-value'
|
|
31
|
+
|
|
32
|
+
export const getSeriesColorAssignments = (config: ChartConfig): SeriesColorAssignment[] =>
|
|
33
|
+
Array.isArray(config.general?.palette?.colorAssignments) ? config.general.palette.colorAssignments : []
|
|
34
|
+
|
|
35
|
+
export const hasSeriesColorAssignmentOverrides = (config: ChartConfig): boolean => {
|
|
36
|
+
if (!supportsSeriesColorAssignments(config) || !isSeriesColorAssignmentModeByValue(config)) return false
|
|
37
|
+
|
|
38
|
+
const currentSeriesKeys = new Set(getSeriesColorAssignmentItems(config).map(({ key }) => key))
|
|
39
|
+
|
|
40
|
+
return getSeriesColorAssignments(config).some(assignment => {
|
|
41
|
+
if (!assignment?.key || !assignment?.color) return false
|
|
42
|
+
|
|
43
|
+
return currentSeriesKeys.has(assignment.key)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const getSeriesDisplayLabel = (config: ChartConfig, key: string): string => {
|
|
48
|
+
const series = config.series?.find(item => item?.dataKey === key)
|
|
49
|
+
return String(config.runtime?.seriesLabels?.[key] || series?.name || (series as any)?.label || key)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const getSeriesColorAssignmentItems = (config: ChartConfig): SeriesColorAssignmentItem[] => {
|
|
53
|
+
if (!supportsSeriesColorAssignments(config) || !Array.isArray(config.series)) return []
|
|
54
|
+
|
|
55
|
+
return config.series
|
|
56
|
+
.filter(series => series?.dataKey && !series.dynamicCategory)
|
|
57
|
+
.map(series => ({
|
|
58
|
+
key: series.dataKey,
|
|
59
|
+
label: getSeriesDisplayLabel(config, series.dataKey)
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const getSeriesKeyByColorScaleLabel = (config: ChartConfig): Map<string, string> => {
|
|
64
|
+
const labelToKey = new Map<string, string>()
|
|
65
|
+
const runtimeKeys = Array.isArray(config.runtime?.seriesKeys) ? config.runtime.seriesKeys : []
|
|
66
|
+
|
|
67
|
+
runtimeKeys.forEach(key => {
|
|
68
|
+
labelToKey.set(String(key), key)
|
|
69
|
+
labelToKey.set(getSeriesDisplayLabel(config, key), key)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
getSeriesColorAssignmentItems(config).forEach(({ key, label }) => {
|
|
73
|
+
labelToKey.set(String(key), key)
|
|
74
|
+
labelToKey.set(label, key)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return labelToKey
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const applySeriesColorAssignmentsToRange = (
|
|
81
|
+
config: ChartConfig,
|
|
82
|
+
domain: string[] = [],
|
|
83
|
+
range: string[] = []
|
|
84
|
+
): string[] => {
|
|
85
|
+
if (!supportsSeriesColorAssignments(config) || !isSeriesColorAssignmentModeByValue(config)) return range
|
|
86
|
+
|
|
87
|
+
const assignmentMap = new Map(
|
|
88
|
+
getSeriesColorAssignments(config)
|
|
89
|
+
.filter(assignment => assignment?.key && assignment?.color)
|
|
90
|
+
.map(assignment => [assignment.key, assignment.color])
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (!assignmentMap.size || !range.length) return range
|
|
94
|
+
|
|
95
|
+
const labelToKey = getSeriesKeyByColorScaleLabel(config)
|
|
96
|
+
|
|
97
|
+
return domain.map((label, index) => {
|
|
98
|
+
const key = labelToKey.get(String(label))
|
|
99
|
+
const assignedColor = key ? assignmentMap.get(key) : undefined
|
|
100
|
+
return assignedColor || range[index % range.length]
|
|
101
|
+
})
|
|
102
|
+
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { Series } from '@cdc/core/types/Series'
|
|
2
|
+
import isNumber from '@cdc/core/helpers/isNumber'
|
|
2
3
|
import { ChartConfig } from '../types/ChartConfig'
|
|
3
4
|
|
|
5
|
+
// For legend suppression (when `legend.unified` is false), treat a series as having
|
|
6
|
+
// data only if the active (filtered) dataset contains at least one numeric, non-zero
|
|
7
|
+
// value for that series. Placeholder strings (e.g. "Not applicable", "<1.0", ""),
|
|
8
|
+
// nullish values, and zeros are treated as "no visible data" for legend purposes.
|
|
9
|
+
const hasGraphableValue = (raw: unknown): boolean => {
|
|
10
|
+
const sanitized = typeof raw === 'string' ? raw.replace(/[,$]/g, '').trim() : raw
|
|
11
|
+
return isNumber(sanitized) && Number(sanitized) !== 0
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export const getSeriesWithData = (config: ChartConfig) => {
|
|
5
15
|
const { filters, data, runtime, legend } = config
|
|
6
16
|
const { colorCode } = legend
|
|
@@ -21,7 +31,7 @@ export const getSeriesWithData = (config: ChartConfig) => {
|
|
|
21
31
|
.filter(d => !s.colorCodeSeries || d[colorCode] === s.colorCodeSeries)
|
|
22
32
|
.filter(d => {
|
|
23
33
|
const key = s.dynamicCategory ? s.originalDataKey : s.dataKey
|
|
24
|
-
return d[key]
|
|
34
|
+
return hasGraphableValue(d[key])
|
|
25
35
|
})
|
|
26
36
|
}))
|
|
27
37
|
.filter(s => s.data.length)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type AxisMaxOverrideProps = {
|
|
2
|
+
minimumValidMax: number
|
|
3
|
+
value: unknown
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const getAxisMaxOverride = ({ minimumValidMax, value }: AxisMaxOverrideProps) => {
|
|
7
|
+
const maxNumber = Number(value)
|
|
8
|
+
const hasEnteredMax = value !== undefined && value !== null && value !== ''
|
|
9
|
+
const hasValidMax = hasEnteredMax && Number.isFinite(maxNumber) && maxNumber >= minimumValidMax
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
hasEnteredMax,
|
|
13
|
+
hasValidMax,
|
|
14
|
+
maxNumber
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const NICE_MANTISSAS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const
|
|
2
|
+
|
|
3
|
+
type FinalizeTopTickMaxProps = {
|
|
4
|
+
currentMax: number
|
|
5
|
+
rawMax: number
|
|
6
|
+
ticks: number[]
|
|
7
|
+
shouldUseCleanTopTick: boolean
|
|
8
|
+
headroomMode?: 'none' | 'inline-label'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const getTickComparisonEpsilon = (tickGap: number) => Math.abs(tickGap) * 1e-10
|
|
12
|
+
|
|
13
|
+
export const getCleanTopTickMax = (value: number) => {
|
|
14
|
+
if (!Number.isFinite(value) || value <= 0) return value
|
|
15
|
+
|
|
16
|
+
const powerOfTenStep = Math.pow(10, Math.floor(Math.log10(value)))
|
|
17
|
+
const normalizedValue = value / powerOfTenStep
|
|
18
|
+
const mantissa = NICE_MANTISSAS.find(candidate => normalizedValue <= candidate)
|
|
19
|
+
|
|
20
|
+
return (mantissa ?? 10) * powerOfTenStep
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const getCleanTopTickMaxCandidates = () => [...NICE_MANTISSAS]
|
|
24
|
+
|
|
25
|
+
export const getFinalTopTickMax = ({
|
|
26
|
+
currentMax,
|
|
27
|
+
rawMax,
|
|
28
|
+
ticks,
|
|
29
|
+
shouldUseCleanTopTick,
|
|
30
|
+
headroomMode = 'none'
|
|
31
|
+
}: FinalizeTopTickMaxProps) => {
|
|
32
|
+
if (!shouldUseCleanTopTick || !Number.isFinite(currentMax)) return { max: currentMax }
|
|
33
|
+
if (!Array.isArray(ticks) || ticks.length === 0) return { max: currentMax }
|
|
34
|
+
|
|
35
|
+
const sortedTicks = Array.from(new Set(ticks.filter(Number.isFinite))).sort((a, b) => a - b)
|
|
36
|
+
if (sortedTicks.length === 0) return { max: currentMax }
|
|
37
|
+
|
|
38
|
+
const tickGap = sortedTicks.slice(1).reduce((smallestGap, tick, index) => {
|
|
39
|
+
const gap = tick - sortedTicks[index]
|
|
40
|
+
return gap > 0 ? Math.min(smallestGap, gap) : smallestGap
|
|
41
|
+
}, Infinity)
|
|
42
|
+
|
|
43
|
+
if (!Number.isFinite(tickGap) || tickGap <= 0) return { max: currentMax }
|
|
44
|
+
|
|
45
|
+
const targetMax = Math.max(currentMax, Number.isFinite(rawMax) ? rawMax : currentMax)
|
|
46
|
+
const tickComparisonEpsilon = getTickComparisonEpsilon(tickGap)
|
|
47
|
+
let finalMax = sortedTicks[sortedTicks.length - 1]
|
|
48
|
+
|
|
49
|
+
while (finalMax + tickComparisonEpsilon < targetMax) {
|
|
50
|
+
finalMax = Number((finalMax + tickGap).toPrecision(15))
|
|
51
|
+
sortedTicks.push(finalMax)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
headroomMode === 'inline-label' &&
|
|
56
|
+
Number.isFinite(rawMax) &&
|
|
57
|
+
finalMax - rawMax <= tickGap * 0.25 + tickComparisonEpsilon
|
|
58
|
+
) {
|
|
59
|
+
finalMax = Number((finalMax + tickGap).toPrecision(15))
|
|
60
|
+
sortedTicks.push(finalMax)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { max: finalMax, tickValues: sortedTicks }
|
|
64
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
divergentColorDistribution,
|
|
11
11
|
colorblindColorDistribution
|
|
12
12
|
} from '@cdc/core/helpers/palettes/colorDistributions'
|
|
13
|
+
import { applySeriesColorAssignmentsToRange } from './colorAssignmentHelpers'
|
|
13
14
|
|
|
14
15
|
export const getColorScale = (config: ChartConfig): ((value: string) => string) => {
|
|
15
16
|
const configPalette = ['Paired Bar', 'Deviation Bar'].includes(config.visualizationType)
|
|
@@ -32,18 +33,24 @@ export const getColorScale = (config: ChartConfig): ((value: string) => string)
|
|
|
32
33
|
// Migrate old palette name if needed
|
|
33
34
|
const migratedPaletteName = configPalette ? configPalette : getFallbackColorPalette(config)
|
|
34
35
|
|
|
36
|
+
const domain = config.runtime.seriesLabelsAll
|
|
37
|
+
|
|
38
|
+
const customColorsOrdered = config.general?.palette?.customColorsOrdered
|
|
39
|
+
|
|
35
40
|
// Check for customColorsOrdered first (direct 1-to-1 mapping, no distribution needed)
|
|
36
|
-
if (
|
|
37
|
-
const
|
|
41
|
+
if (Array.isArray(customColorsOrdered) && customColorsOrdered.length > 0) {
|
|
42
|
+
const range = applySeriesColorAssignmentsToRange(config, domain, customColorsOrdered)
|
|
38
43
|
return scaleOrdinal({
|
|
39
|
-
domain
|
|
40
|
-
range
|
|
44
|
+
domain,
|
|
45
|
+
range,
|
|
41
46
|
unknown: null
|
|
42
47
|
})
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
const customColors = config.general?.palette?.customColors
|
|
51
|
+
const isUsingCustomColors = Array.isArray(customColors) && customColors.length > 0
|
|
45
52
|
let palette =
|
|
46
|
-
|
|
53
|
+
(isUsingCustomColors ? customColors : undefined) ||
|
|
47
54
|
palettesSource[migratePaletteWithMap(migratedPaletteName, paletteMigrationMap, false)] ||
|
|
48
55
|
palettesSource[configPalette]
|
|
49
56
|
|
|
@@ -58,8 +65,8 @@ export const getColorScale = (config: ChartConfig): ((value: string) => string)
|
|
|
58
65
|
// Apply enhanced color distribution (same logic as pie charts)
|
|
59
66
|
const paletteVersion = getColorPaletteVersion(config)
|
|
60
67
|
|
|
61
|
-
// Skip enhanced distribution if not v2, too many keys, or wrong palette length
|
|
62
|
-
if (paletteVersion !== 2 || numberOfKeys > 9 || palette.length !== 9) {
|
|
68
|
+
// Skip enhanced distribution if using custom colors, not v2, too many keys, or wrong palette length
|
|
69
|
+
if (isUsingCustomColors || paletteVersion !== 2 || numberOfKeys > 9 || palette.length !== 9) {
|
|
63
70
|
// Use existing logic for v1 palettes and other cases
|
|
64
71
|
while (numberOfKeys > palette.length) {
|
|
65
72
|
palette = palette.concat(palette)
|
|
@@ -90,9 +97,11 @@ export const getColorScale = (config: ChartConfig): ((value: string) => string)
|
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
const range = applySeriesColorAssignmentsToRange(config, domain, palette)
|
|
101
|
+
|
|
93
102
|
return scaleOrdinal({
|
|
94
|
-
domain
|
|
95
|
-
range
|
|
103
|
+
domain,
|
|
104
|
+
range,
|
|
96
105
|
unknown: null
|
|
97
106
|
})
|
|
98
107
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import _ from 'lodash'
|
|
2
|
+
import { ChartConfig } from '../types/ChartConfig'
|
|
3
|
+
|
|
4
|
+
type GetHasBoundarySuppressionProps = {
|
|
5
|
+
rows?: Object[]
|
|
6
|
+
config: ChartConfig
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const getHasBoundarySuppression = ({ rows, config }: GetHasBoundarySuppressionProps) => {
|
|
10
|
+
if (!Array.isArray(rows) || rows.length === 0) return false
|
|
11
|
+
const suppressionRules = config.preliminaryData?.filter(({ type, style }) => type === 'suppression' && style) || []
|
|
12
|
+
if (!suppressionRules.length) return false
|
|
13
|
+
|
|
14
|
+
const boundaryRows = [rows[0], rows[rows.length - 1]].filter(Boolean)
|
|
15
|
+
|
|
16
|
+
return boundaryRows.some(item =>
|
|
17
|
+
suppressionRules.some(({ column, value }) => {
|
|
18
|
+
const values = _.values(_.pick(item, config.runtime?.seriesKeys))
|
|
19
|
+
const dynamicCategory = config.series[0]?.dynamicCategory
|
|
20
|
+
const match = column ? item[column] === value : values.includes(value)
|
|
21
|
+
const dynamic = dynamicCategory && (item[dynamicCategory] === column || !column)
|
|
22
|
+
|
|
23
|
+
return Boolean(match || dynamic)
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
}
|
package/src/helpers/getMinMax.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ChartConfig } from '../types/ChartConfig'
|
|
2
|
-
import
|
|
2
|
+
import { getCleanTopTickMax } from './getCleanTopTickMax'
|
|
3
|
+
import { getAxisMaxOverride } from './getAxisMaxOverride'
|
|
3
4
|
|
|
4
5
|
type GetMinMaxProps = {
|
|
5
6
|
/** config - standard chart config */
|
|
@@ -18,6 +19,8 @@ type GetMinMaxProps = {
|
|
|
18
19
|
isAllLine: boolean
|
|
19
20
|
/** convertLineToBarGraph - whether line charts should be rendered as bar graphs */
|
|
20
21
|
convertLineToBarGraph?: boolean
|
|
22
|
+
/** whether selected-domain boundary rows include suppression tokens */
|
|
23
|
+
hasBoundarySuppression?: boolean
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
const getMinMax = ({
|
|
@@ -28,7 +31,8 @@ const getMinMax = ({
|
|
|
28
31
|
data,
|
|
29
32
|
isAllLine,
|
|
30
33
|
tableData,
|
|
31
|
-
convertLineToBarGraph
|
|
34
|
+
convertLineToBarGraph,
|
|
35
|
+
hasBoundarySuppression = false
|
|
32
36
|
}: GetMinMaxProps) => {
|
|
33
37
|
let min = 0
|
|
34
38
|
let max = 0
|
|
@@ -45,14 +49,18 @@ const getMinMax = ({
|
|
|
45
49
|
const { max: enteredMaxValue, min: enteredMinValue } = config.runtime.yAxis
|
|
46
50
|
const paddingAddedToAxis = config.yAxis.enablePadding ? 1 + config.yAxis.scalePadding / 100 : 1
|
|
47
51
|
const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
|
|
48
|
-
//
|
|
49
|
-
const
|
|
52
|
+
// Validate before applying chart-specific domain rules.
|
|
53
|
+
const { hasValidMax: hasValidExplicitLeftMax, maxNumber: enteredMaxNumber } = getAxisMaxOverride({
|
|
54
|
+
value: enteredMaxValue,
|
|
55
|
+
minimumValidMax: existPositiveValue ? maxValue : 0
|
|
56
|
+
})
|
|
57
|
+
const shouldCleanAutoLeftMax = config.yAxis.autoMaxStrategy === 'clean-top-tick' && !hasValidExplicitLeftMax
|
|
50
58
|
const isMinValid = isLogarithmicAxis
|
|
51
59
|
? Number(enteredMinValue) >= 0
|
|
52
60
|
: (Number(enteredMinValue) <= 0 && minValue >= 0) || (Number(enteredMinValue) <= minValue && minValue < 0)
|
|
53
61
|
|
|
54
62
|
min = enteredMinValue && isMinValid ? Number(enteredMinValue) : minValue
|
|
55
|
-
max =
|
|
63
|
+
max = hasValidExplicitLeftMax ? enteredMaxNumber : Number.MIN_VALUE
|
|
56
64
|
const { lower, upper } = config?.confidenceKeys || {}
|
|
57
65
|
|
|
58
66
|
const useBrushFullRange = config.xAxis.brushActive && !config.xAxis.brushDynamicYAxis
|
|
@@ -135,8 +143,8 @@ const getMinMax = ({
|
|
|
135
143
|
leftMax = findMaxFromSeriesKeys(dataForMinMax, leftAxisSeriesItems, leftMax, 'left')
|
|
136
144
|
rightMax = findMaxFromSeriesKeys(dataForMinMax, rightAxisSeriesItems, rightMax, 'right')
|
|
137
145
|
|
|
138
|
-
if (leftMax <
|
|
139
|
-
leftMax =
|
|
146
|
+
if (hasValidExplicitLeftMax && leftMax < enteredMaxNumber) {
|
|
147
|
+
leftMax = enteredMaxNumber
|
|
140
148
|
}
|
|
141
149
|
} catch (e) {
|
|
142
150
|
console.error(e.message)
|
|
@@ -180,23 +188,9 @@ const getMinMax = ({
|
|
|
180
188
|
const numEnteredMin = Number(enteredMinValue)
|
|
181
189
|
const isMinValid = isLogarithmicAxis ? numEnteredMin >= 0 && numEnteredMin < minValue : numEnteredMin < minValue
|
|
182
190
|
|
|
183
|
-
const suppressedMinValue = tableData?.some((item, i, arr) =>
|
|
184
|
-
config.preliminaryData?.some(({ type, style, column, value }) => {
|
|
185
|
-
if (type !== 'suppression' || !style) return false
|
|
186
|
-
|
|
187
|
-
const values = _.values(_.pick(item, config.runtime?.seriesKeys))
|
|
188
|
-
const dynamicCategory = config.series[0].dynamicCategory
|
|
189
|
-
|
|
190
|
-
const match = column ? item[column] === value : values.includes(value)
|
|
191
|
-
const dynamic = dynamicCategory && (item[dynamicCategory] === column || !column)
|
|
192
|
-
|
|
193
|
-
return (match || dynamic) && (i === 0 || i === arr.length - 1)
|
|
194
|
-
})
|
|
195
|
-
)
|
|
196
|
-
|
|
197
191
|
const isCategorical = config.yAxis.type === 'categorical'
|
|
198
192
|
|
|
199
|
-
min = enteredMinValue !== '' && isMinValid ? numEnteredMin :
|
|
193
|
+
min = enteredMinValue !== '' && isMinValid ? numEnteredMin : hasBoundarySuppression ? 0 : isCategorical ? 0 : minValue
|
|
200
194
|
}
|
|
201
195
|
|
|
202
196
|
//If data value max wasn't provided, calculate it
|
|
@@ -205,6 +199,14 @@ const getMinMax = ({
|
|
|
205
199
|
max = existPositiveValue ? maxValue : 0
|
|
206
200
|
}
|
|
207
201
|
|
|
202
|
+
if (shouldCleanAutoLeftMax) {
|
|
203
|
+
if (config.visualizationType === 'Combo') {
|
|
204
|
+
leftMax = getCleanTopTickMax(leftMax)
|
|
205
|
+
} else {
|
|
206
|
+
max = getCleanTopTickMax(max)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
208
210
|
//Adds Y Axis data padding if applicable
|
|
209
211
|
if (config.runtime.yAxis.paddingPercent) {
|
|
210
212
|
let paddingValue = (max - min) * config.runtime.yAxis.paddingPercent
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ChartConfig } from '../types/ChartConfig'
|
|
2
|
+
import { AxisFilterDomainBehavior } from '@cdc/core/types/Axis'
|
|
3
|
+
|
|
4
|
+
const hasRows = (data?: Object[]) => Array.isArray(data) && data.length > 0
|
|
5
|
+
|
|
6
|
+
export const getYAxisFilterDomainBehavior = (config: ChartConfig): AxisFilterDomainBehavior => {
|
|
7
|
+
return config.yAxis?.filterDomainBehavior === 'stable' ? 'stable' : 'dynamic'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const getYAxisDomainData = ({
|
|
11
|
+
config,
|
|
12
|
+
data,
|
|
13
|
+
tableData,
|
|
14
|
+
fullEligibleDomainData
|
|
15
|
+
}: {
|
|
16
|
+
config: ChartConfig
|
|
17
|
+
data: Object[]
|
|
18
|
+
tableData?: Object[]
|
|
19
|
+
fullEligibleDomainData?: Object[]
|
|
20
|
+
}): Object[] => {
|
|
21
|
+
const brushActive = Boolean(config.xAxis?.brushActive)
|
|
22
|
+
const brushDynamicYAxis = Boolean(config.xAxis?.brushDynamicYAxis)
|
|
23
|
+
|
|
24
|
+
if (brushActive && brushDynamicYAxis) {
|
|
25
|
+
return data
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const behavior = getYAxisFilterDomainBehavior(config)
|
|
29
|
+
if (behavior === 'stable' && hasRows(fullEligibleDomainData)) {
|
|
30
|
+
return fullEligibleDomainData
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (brushActive && !brushDynamicYAxis && hasRows(tableData)) {
|
|
34
|
+
return tableData
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return data
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ChartConfig } from '../types/ChartConfig'
|
|
2
|
+
|
|
3
|
+
type YAxisFinalizationEligibilityProps = {
|
|
4
|
+
config: ChartConfig
|
|
5
|
+
hasSpacedInlineLabel: boolean
|
|
6
|
+
hasValidExplicitLeftMax: boolean
|
|
7
|
+
isHorizontal: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const chartUsesStandardPrimaryYScale = (config: ChartConfig) => {
|
|
11
|
+
return !['Box Plot', 'Bump Chart', 'Forest Plot'].includes(config.visualizationType)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isLinearOrLegacyYAxis = (config: ChartConfig) => {
|
|
15
|
+
const yAxisType = config.yAxis.type
|
|
16
|
+
|
|
17
|
+
return yAxisType === undefined || yAxisType === '' || yAxisType === 'linear'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const getYAxisFinalizationEligibility = ({
|
|
21
|
+
config,
|
|
22
|
+
hasSpacedInlineLabel,
|
|
23
|
+
hasValidExplicitLeftMax,
|
|
24
|
+
isHorizontal
|
|
25
|
+
}: YAxisFinalizationEligibilityProps) => {
|
|
26
|
+
const hasStandardLinearPrimaryYAxis =
|
|
27
|
+
!isHorizontal && isLinearOrLegacyYAxis(config) && chartUsesStandardPrimaryYScale(config)
|
|
28
|
+
const supportsInlineLabelHeadroom = hasStandardLinearPrimaryYAxis && config.visualizationType !== 'Combo'
|
|
29
|
+
const shouldUseInlineLabelHeadroom = hasSpacedInlineLabel && supportsInlineLabelHeadroom
|
|
30
|
+
const shouldUseCleanTopTick =
|
|
31
|
+
!hasValidExplicitLeftMax && (config.yAxis.autoMaxStrategy === 'clean-top-tick' || shouldUseInlineLabelHeadroom)
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
hasStandardLinearPrimaryYAxis,
|
|
35
|
+
supportsInlineLabelHeadroom,
|
|
36
|
+
shouldUseCleanTopTick,
|
|
37
|
+
shouldUseInlineLabelHeadroom,
|
|
38
|
+
shouldFinalizePrimaryYAxis: shouldUseCleanTopTick && hasStandardLinearPrimaryYAxis
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ChartConfig } from '../types/ChartConfig'
|
|
2
|
+
|
|
3
|
+
export const hasSpacedInlineLabel = (config: ChartConfig): boolean => {
|
|
4
|
+
const inlineLabel = config.yAxis?.inlineLabel
|
|
5
|
+
if (inlineLabel && inlineLabel.includes(' ')) {
|
|
6
|
+
return true
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
@@ -11,6 +11,7 @@ type ColumnFormattingParams = {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const hasOwn = (object: object, key: keyof Column) => Object.prototype.hasOwnProperty.call(object, key)
|
|
14
|
+
const isNonEmptyString = (value: unknown) => typeof value === 'string' && value !== ''
|
|
14
15
|
|
|
15
16
|
export const createDefaultSeriesColumnConfig = (columnName: string): Column => ({
|
|
16
17
|
name: columnName,
|
|
@@ -94,12 +95,12 @@ export const getSeriesColumnFormattingParams = (columnConfig?: Partial<Column>):
|
|
|
94
95
|
|
|
95
96
|
const formattingParams: ColumnFormattingParams = {}
|
|
96
97
|
|
|
97
|
-
if (hasOwn(columnConfig, 'prefix')) {
|
|
98
|
-
formattingParams.addColPrefix = columnConfig.prefix
|
|
98
|
+
if (hasOwn(columnConfig, 'prefix') && isNonEmptyString(columnConfig.prefix)) {
|
|
99
|
+
formattingParams.addColPrefix = columnConfig.prefix
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
if (hasOwn(columnConfig, 'suffix')) {
|
|
102
|
-
formattingParams.addColSuffix = columnConfig.suffix
|
|
102
|
+
if (hasOwn(columnConfig, 'suffix') && isNonEmptyString(columnConfig.suffix)) {
|
|
103
|
+
formattingParams.addColSuffix = columnConfig.suffix
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
if (hasOwn(columnConfig, 'roundToPlace')) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { scaleOrdinal } from '@visx/scale'
|
|
2
2
|
import { getColorScale } from './getColorScale'
|
|
3
3
|
import { ColorScale } from '../types/ChartContext'
|
|
4
|
+
import { hasSeriesColorAssignmentOverrides } from './colorAssignmentHelpers'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Get filtered data for a specific tile based on its mode
|
|
@@ -206,6 +207,10 @@ export const getFullColorPalette = (config, numberOfTiles) => {
|
|
|
206
207
|
export const createTileColorScale = (tileItem, config, originalColorScale, tileIndex, numberOfTiles) => {
|
|
207
208
|
const colorMode = config.smallMultiples?.colorMode || 'same'
|
|
208
209
|
|
|
210
|
+
if (tileItem.mode === 'by-series' && hasSeriesColorAssignmentOverrides(config)) {
|
|
211
|
+
return originalColorScale
|
|
212
|
+
}
|
|
213
|
+
|
|
209
214
|
if (colorMode === 'same') {
|
|
210
215
|
if (tileItem.mode === 'by-series') {
|
|
211
216
|
// Same mode + by-series: All tiles use the same color
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createMockConfig } from '../../components/LinearChart/tests/mockConfigContext'
|
|
3
|
+
|
|
4
|
+
vi.mock('@cdc/core/helpers/getTextWidth', () => ({
|
|
5
|
+
getTextWidth: (text: string) => text.length
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import { calculateLeftYAxisWidth } from '../calculateLeftYAxisWidth'
|
|
9
|
+
|
|
10
|
+
describe('calculateLeftYAxisWidth', () => {
|
|
11
|
+
it('prefers supplied tick values when measuring labels', () => {
|
|
12
|
+
const handleLeftTickFormatting = vi.fn(tick => String(tick))
|
|
13
|
+
const yScale = {
|
|
14
|
+
ticks: vi.fn(() => [0, 5000, 10000]),
|
|
15
|
+
domain: vi.fn(() => [0, 10000])
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
calculateLeftYAxisWidth({
|
|
19
|
+
config: createMockConfig({
|
|
20
|
+
yAxis: {
|
|
21
|
+
...createMockConfig().yAxis,
|
|
22
|
+
titlePlacement: 'side'
|
|
23
|
+
}
|
|
24
|
+
}),
|
|
25
|
+
data: [],
|
|
26
|
+
yScale,
|
|
27
|
+
numTicks: 3,
|
|
28
|
+
tickValues: [0, 5, 10],
|
|
29
|
+
parentWidth: 400,
|
|
30
|
+
tickLabelFont: 'normal 16px sans-serif',
|
|
31
|
+
axisLabelFontSize: 16,
|
|
32
|
+
handleLeftTickFormatting
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(yScale.ticks).not.toHaveBeenCalled()
|
|
36
|
+
expect(handleLeftTickFormatting.mock.calls.map(call => call[0])).toEqual([0, 5, 10])
|
|
37
|
+
})
|
|
38
|
+
})
|