@cdc/chart 4.26.4 → 4.26.5
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 +349 -29
- package/LICENSE +201 -0
- package/dist/cdcchart.js +34247 -32805
- package/examples/feature/heatmap/average-age-categorical.json +313 -0
- package/examples/feature/heatmap/calendar-heatmap.json +134 -0
- package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
- package/examples/feature/heatmap/sparse-aggregation.json +158 -0
- package/examples/private/bump-chart.json +1116 -0
- package/examples/private/test-123.json +624 -0
- package/package.json +4 -3
- package/src/CdcChartComponent.tsx +135 -54
- package/src/_stories/Chart.AltText.stories.tsx +136 -0
- package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
- package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
- package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
- package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
- package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
- package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
- package/src/_stories/_mock/alt_text_metadata.json +38 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
- package/src/_stories/_mock/heatmap.json +156 -0
- package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
- package/src/_stories/_mock/sankey_mock.json +78 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
- package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
- package/src/components/Axis/LeftAxis.tsx +14 -11
- package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
- package/src/components/Brush/BrushSelector.tsx +88 -187
- package/src/components/Brush/brushInteraction.ts +22 -0
- package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
- package/src/components/EditorPanel/EditorPanel.tsx +310 -49
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
- package/src/components/HeatMap/components/HeatMap.tsx +762 -0
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
- package/src/components/HeatMap/heatmap.constants.ts +13 -0
- package/src/components/HeatMap/heatmap.css +71 -0
- package/src/components/HeatMap/helpers/index.ts +261 -0
- package/src/components/HeatMap/index.tsx +5 -0
- package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
- package/src/components/HeatMap/tests/helpers.test.ts +167 -0
- package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
- package/src/components/LineChart/helpers.test.ts +345 -0
- package/src/components/LineChart/helpers.ts +15 -5
- package/src/components/LineChart/index.tsx +7 -1
- package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
- package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
- package/src/components/LinearChart.tsx +58 -37
- package/src/components/PieChart/PieChart.tsx +4 -1
- package/src/components/RadarChart/RadarChart.tsx +3 -1
- package/src/components/Regions/components/Regions.tsx +195 -41
- package/src/components/Sankey/components/Sankey.tsx +7 -3
- package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
- package/src/components/SmallMultiples/SmallMultiples.css +12 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
- package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
- package/src/components/Sparkline/components/SparkLine.tsx +4 -9
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
- package/src/data/initial-state.js +11 -0
- package/src/helpers/axisLabelFontSize.ts +8 -0
- package/src/helpers/bumpChartTooltip.ts +54 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
- package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
- package/src/helpers/getMinMax.ts +2 -3
- package/src/helpers/getYAxisAutoPadding.ts +8 -6
- package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
- package/src/helpers/handleChartAriaLabels.ts +23 -10
- package/src/helpers/seriesHighlight.ts +26 -0
- package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
- package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
- package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
- package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
- package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
- package/src/helpers/tests/seriesHighlight.test.ts +61 -0
- package/src/hooks/tests/useScales.test.tsx +105 -0
- package/src/hooks/useScales.ts +19 -9
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +32 -49
- package/src/types/ChartConfig.ts +18 -0
- package/src/types/ChartContext.ts +2 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useContext, useMemo } from 'react'
|
|
2
|
+
import { LegendItem, LegendLabel } from '@visx/legend'
|
|
3
|
+
import LegendShape from '@cdc/core/components/LegendShape'
|
|
4
|
+
import ConfigContext from '../../../ConfigContext'
|
|
5
|
+
import { buildHeatMapData, getHeatMapBucketPalette, getHeatMapDataGroupRanges } from '../helpers'
|
|
6
|
+
import { ChartConfig } from '../../../types/ChartConfig'
|
|
7
|
+
|
|
8
|
+
const getHeatMapLegendClasses = (config: ChartConfig) => {
|
|
9
|
+
const { position = 'top', singleRow = true, verticalSorted } = config.legend || {}
|
|
10
|
+
const hideBorder = config.legend?.hideBorder
|
|
11
|
+
const hideSideBorder = hideBorder && typeof hideBorder === 'object' ? hideBorder.side : Boolean(hideBorder)
|
|
12
|
+
const hideTopBottomBorder = hideBorder && typeof hideBorder === 'object' ? hideBorder.topBottom : Boolean(hideBorder)
|
|
13
|
+
const containerClasses = ['legend-container', 'cdc-heatmap__legend', position].filter(Boolean)
|
|
14
|
+
const innerClasses = ['legend-container__inner']
|
|
15
|
+
|
|
16
|
+
if (['bottom', 'top'].includes(position)) {
|
|
17
|
+
innerClasses.push(singleRow ? 'single-row' : 'double-column', position)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (['bottom', 'top'].includes(position) && verticalSorted) {
|
|
21
|
+
innerClasses.push('vertical-sorted')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
(['right', 'left'].includes(position) && hideSideBorder) ||
|
|
26
|
+
(['top', 'bottom'].includes(position) && hideTopBottomBorder)
|
|
27
|
+
) {
|
|
28
|
+
containerClasses.push('border-0', 'p-0')
|
|
29
|
+
} else {
|
|
30
|
+
containerClasses.push('p-3')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { containerClasses, innerClasses }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HeatMapGradientLegend = () => {
|
|
37
|
+
const { filteredData, excludedData, config, formatNumber, parseDate } = useContext(ConfigContext)
|
|
38
|
+
|
|
39
|
+
const rows = filteredData?.length ? filteredData : excludedData || []
|
|
40
|
+
const xDataKey = config.xAxis?.dataKey
|
|
41
|
+
const heatMapSeries = config.series || []
|
|
42
|
+
|
|
43
|
+
const { minValue, maxValue } = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
buildHeatMapData({
|
|
46
|
+
data: rows as Record<string, any>[],
|
|
47
|
+
xDataKey,
|
|
48
|
+
series: heatMapSeries,
|
|
49
|
+
seriesLabels: config.runtime?.seriesLabels,
|
|
50
|
+
xAxisType: config.xAxis?.type,
|
|
51
|
+
parseDate
|
|
52
|
+
}),
|
|
53
|
+
[rows, xDataKey, heatMapSeries, config.runtime?.seriesLabels, config.xAxis?.type, parseDate]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const palette = getHeatMapBucketPalette(config)
|
|
57
|
+
const legendLabel = config.legend?.label
|
|
58
|
+
const valueLabel = legendLabel ? undefined : 'Value'
|
|
59
|
+
const { containerClasses, innerClasses } = getHeatMapLegendClasses(config)
|
|
60
|
+
const isGradientLegend = !config.legend?.style || config.legend?.style === 'gradient'
|
|
61
|
+
const shape = config.legend?.style === 'circles' ? 'circle' : 'square'
|
|
62
|
+
const ranges = getHeatMapDataGroupRanges(config, minValue, maxValue, formatNumber)
|
|
63
|
+
const displayRanges = config.legend?.reverseLabelOrder ? [...ranges].reverse() : ranges
|
|
64
|
+
const displayPalette = config.legend?.reverseLabelOrder ? [...palette].reverse() : palette
|
|
65
|
+
|
|
66
|
+
if (config.legend?.hide) return null
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<aside id='legend' className={containerClasses.join(' ')} role='region' aria-label='legend' tabIndex={0}>
|
|
70
|
+
{config.legend?.label && <h3 className='cdc-heatmap__legend-title'>{config.legend.label}</h3>}
|
|
71
|
+
{config.legend?.description && <p className='cdc-heatmap__legend-description'>{config.legend.description}</p>}
|
|
72
|
+
|
|
73
|
+
{isGradientLegend ? (
|
|
74
|
+
<div className='cdc-heatmap__legend-scale'>
|
|
75
|
+
<svg className='cdc-heatmap__legend-svg' height='50' width='100%'>
|
|
76
|
+
<rect x='0' y='0' width='100%' height='25' fill='#d3d3d3' />
|
|
77
|
+
{displayPalette.map((color, index) => (
|
|
78
|
+
<rect
|
|
79
|
+
key={`${color}-${index}`}
|
|
80
|
+
x={`${(index / displayPalette.length) * 100}%`}
|
|
81
|
+
y='1'
|
|
82
|
+
width={`${100 / displayPalette.length}%`}
|
|
83
|
+
height='23'
|
|
84
|
+
fill={color}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
|
|
88
|
+
<text x='0' y='40' fontSize='14' textAnchor='start' fill='#333'>
|
|
89
|
+
{displayRanges[0]?.label.split('\u2013')[0]}
|
|
90
|
+
</text>
|
|
91
|
+
<text x='100%' y='40' fontSize='14' textAnchor='end' fill='#333'>
|
|
92
|
+
{displayRanges[displayRanges.length - 1]?.label.split('\u2013').pop()}
|
|
93
|
+
</text>
|
|
94
|
+
</svg>
|
|
95
|
+
|
|
96
|
+
{valueLabel && <div className='cdc-heatmap__legend-series-label'>{valueLabel}</div>}
|
|
97
|
+
</div>
|
|
98
|
+
) : (
|
|
99
|
+
<div className={innerClasses.join(' ')}>
|
|
100
|
+
{displayRanges.map((range, index) => (
|
|
101
|
+
<LegendItem className='legend-item not-clickable' key={`heatmap-legend-range-${index}`} tabIndex={-1}>
|
|
102
|
+
<LegendShape shape={shape} fill={displayPalette[index % displayPalette.length]} />
|
|
103
|
+
<LegendLabel align='left' className='m-0'>
|
|
104
|
+
{range.label}
|
|
105
|
+
</LegendLabel>
|
|
106
|
+
</LegendItem>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</aside>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default HeatMapGradientLegend
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type HeatMapConfig } from '../../types/ChartConfig'
|
|
2
|
+
|
|
3
|
+
export const MIN_HEATMAP_COLOR_BUCKETS = 1
|
|
4
|
+
export const MAX_HEATMAP_COLOR_BUCKETS = 9
|
|
5
|
+
|
|
6
|
+
export const HEATMAP_CONFIG_DEFAULTS: Required<HeatMapConfig> = {
|
|
7
|
+
cellPadding: 1,
|
|
8
|
+
rowLabelGap: 32,
|
|
9
|
+
columnLabelGap: 56,
|
|
10
|
+
colorBucketCount: 9,
|
|
11
|
+
xAxisPosition: 'top',
|
|
12
|
+
showCellValues: false
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
.cove-visualization .cdc-heatmap {
|
|
2
|
+
--cove-heatmap-empty-cell-color: var(--lightestGray, #f3f4f6);
|
|
3
|
+
--cove-heatmap-cell-stroke-color: rgba(255, 255, 255, 0.7);
|
|
4
|
+
--cove-heatmap-cell-stroke-width: 0.1px;
|
|
5
|
+
--cove-heatmap-axis-title-color: var(--darkGray, #333);
|
|
6
|
+
--cove-heatmap-axis-title-font-size: 14px;
|
|
7
|
+
|
|
8
|
+
width: 100%;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.cove-visualization .cdc-heatmap__cell {
|
|
12
|
+
stroke: var(--cove-heatmap-cell-stroke-color);
|
|
13
|
+
stroke-width: var(--cove-heatmap-cell-stroke-width);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.cove-visualization .cdc-heatmap__cell--empty {
|
|
17
|
+
fill: var(--cove-heatmap-empty-cell-color);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.cove-visualization .cdc-heatmap__cell-value {
|
|
21
|
+
font-weight: 700;
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
user-select: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.cove-visualization .cdc-heatmap__axis-title {
|
|
27
|
+
fill: var(--cove-heatmap-axis-title-color);
|
|
28
|
+
font-size: var(--cove-heatmap-axis-title-font-size);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.cove-visualization .cdc-heatmap__body-wrap {
|
|
32
|
+
row-gap: 1rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.cove-visualization .cdc-heatmap__container .legend-wrapper {
|
|
36
|
+
row-gap: 0.75rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.cove-visualization
|
|
40
|
+
.cdc-heatmap__container.legend-bottom:not(.legend-hidden)
|
|
41
|
+
.legend-wrapper
|
|
42
|
+
aside.cdc-heatmap__legend {
|
|
43
|
+
margin-top: 0 !important;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.cove-visualization .cdc-heatmap__legend .cdc-heatmap__legend-title {
|
|
47
|
+
font-size: var(--legend-title-font-size);
|
|
48
|
+
font-weight: 700;
|
|
49
|
+
margin: 0 0 0.5rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.cove-visualization .cdc-heatmap__legend .cdc-heatmap__legend-description {
|
|
53
|
+
margin: 0 0 0.75rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.cove-visualization .cdc-heatmap__legend .cdc-heatmap__legend-scale {
|
|
57
|
+
width: 100%;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.cove-visualization .cdc-heatmap__legend .cdc-heatmap__legend-svg {
|
|
61
|
+
display: block;
|
|
62
|
+
max-width: 100%;
|
|
63
|
+
overflow: visible;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.cove-visualization .cdc-heatmap__legend .cdc-heatmap__legend-series-label {
|
|
67
|
+
color: #333;
|
|
68
|
+
font-weight: 600;
|
|
69
|
+
margin-top: 0.25rem;
|
|
70
|
+
text-align: center;
|
|
71
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { scaleQuantize } from 'd3-scale'
|
|
2
|
+
import { interpolateRgbBasis } from 'd3-interpolate'
|
|
3
|
+
import { ChartConfig } from '../../types/ChartConfig'
|
|
4
|
+
import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
|
|
5
|
+
import { getFallbackColorPalette, migratePaletteWithMap } from '@cdc/core/helpers/palettes/utils'
|
|
6
|
+
import { paletteMigrationMap } from '@cdc/core/helpers/palettes/migratePaletteName'
|
|
7
|
+
import { HEATMAP_CONFIG_DEFAULTS, MAX_HEATMAP_COLOR_BUCKETS, MIN_HEATMAP_COLOR_BUCKETS } from '../heatmap.constants'
|
|
8
|
+
|
|
9
|
+
export type HeatMapSourceRow = Record<string, any>
|
|
10
|
+
|
|
11
|
+
export type HeatMapCell = {
|
|
12
|
+
rowKey: string
|
|
13
|
+
rowLabel: string
|
|
14
|
+
xValue: any
|
|
15
|
+
xLabel: string
|
|
16
|
+
value: number | null
|
|
17
|
+
sourceRows: HeatMapSourceRow[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type HeatMapColumn = {
|
|
21
|
+
key: string
|
|
22
|
+
label: string
|
|
23
|
+
rawValue: any
|
|
24
|
+
bins: HeatMapCell[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type HeatMapDataGroupRange = {
|
|
28
|
+
min: number
|
|
29
|
+
max: number
|
|
30
|
+
label: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type HeatMapSeries = { dataKey: string; name?: string }
|
|
34
|
+
type FormatLegendValueFn = (value: number, axis?: string) => string
|
|
35
|
+
|
|
36
|
+
type BuildHeatMapDataOptions = {
|
|
37
|
+
data: HeatMapSourceRow[]
|
|
38
|
+
xDataKey: string
|
|
39
|
+
series?: HeatMapSeries[]
|
|
40
|
+
seriesLabels?: Record<string, string>
|
|
41
|
+
xAxisType?: string
|
|
42
|
+
parseDate?: (value: string, showError?: boolean) => Date
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_HEATMAP_PALETTE = [
|
|
46
|
+
'#eff6fb',
|
|
47
|
+
'#d8e8f7',
|
|
48
|
+
'#bdd9ef',
|
|
49
|
+
'#96c0e0',
|
|
50
|
+
'#67a3ce',
|
|
51
|
+
'#4286be',
|
|
52
|
+
'#2068aa',
|
|
53
|
+
'#0c4f8f',
|
|
54
|
+
'#08306b'
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const toDisplayLabel = (value: any): string => {
|
|
58
|
+
if (value === null || value === undefined || value === '') return 'No Data'
|
|
59
|
+
return String(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isDateAxisType = (xAxisType?: string) => ['date', 'date-time'].includes(xAxisType || '')
|
|
63
|
+
|
|
64
|
+
const getSeriesLabel = (seriesEntry: HeatMapSeries, seriesLabels?: Record<string, string>) =>
|
|
65
|
+
seriesLabels?.[seriesEntry.dataKey] || seriesEntry.name || seriesEntry.dataKey
|
|
66
|
+
|
|
67
|
+
const createEmptyCell = (xValue: any, rowKey: string, rowLabel: string): HeatMapCell => ({
|
|
68
|
+
rowKey,
|
|
69
|
+
rowLabel,
|
|
70
|
+
xValue,
|
|
71
|
+
xLabel: toDisplayLabel(xValue),
|
|
72
|
+
value: null,
|
|
73
|
+
sourceRows: []
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const getCellKey = (xValue: any, rowKey: string) => `${String(xValue)}__${String(rowKey)}`
|
|
77
|
+
|
|
78
|
+
const getHeatMapValueDomain = (aggregated: Map<string, HeatMapCell>) => {
|
|
79
|
+
const values = Array.from(aggregated.values())
|
|
80
|
+
.map(cell => cell.value)
|
|
81
|
+
.filter((value): value is number => typeof value === 'number' && !Number.isNaN(value))
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
minValue: values.length ? Math.min(...values) : 0,
|
|
85
|
+
maxValue: values.length ? Math.max(...values) : 0
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sortXValues = (
|
|
90
|
+
values: any[],
|
|
91
|
+
xAxisType: string | undefined,
|
|
92
|
+
parseDate?: (value: string, showError?: boolean) => Date
|
|
93
|
+
) => {
|
|
94
|
+
if (!isDateAxisType(xAxisType)) {
|
|
95
|
+
return values
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [...values].sort((a, b) => {
|
|
99
|
+
const left = parseDate ? parseDate(String(a), false).getTime() : new Date(a).getTime()
|
|
100
|
+
const right = parseDate ? parseDate(String(b), false).getTime() : new Date(b).getTime()
|
|
101
|
+
return left - right
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const buildHeatMapData = ({
|
|
106
|
+
data,
|
|
107
|
+
xDataKey,
|
|
108
|
+
series,
|
|
109
|
+
seriesLabels,
|
|
110
|
+
xAxisType,
|
|
111
|
+
parseDate
|
|
112
|
+
}: BuildHeatMapDataOptions) => {
|
|
113
|
+
const rows = Array.isArray(data) ? data : []
|
|
114
|
+
const hasSeriesMode = Array.isArray(series) && series.length > 0
|
|
115
|
+
|
|
116
|
+
if (!xDataKey || rows.length === 0 || !hasSeriesMode) {
|
|
117
|
+
return { columns: [] as HeatMapColumn[], rowLabels: [] as string[], minValue: 0, maxValue: 0 }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const xValues = Array.from(new Set(rows.map(row => row[xDataKey])))
|
|
121
|
+
const orderedXValues = sortXValues(xValues, xAxisType, parseDate)
|
|
122
|
+
|
|
123
|
+
const aggregated = new Map<string, HeatMapCell>()
|
|
124
|
+
|
|
125
|
+
rows.forEach(row => {
|
|
126
|
+
const xValue = row[xDataKey]
|
|
127
|
+
|
|
128
|
+
series.forEach(seriesEntry => {
|
|
129
|
+
const numericValue = Number(row[seriesEntry.dataKey])
|
|
130
|
+
if (Number.isNaN(numericValue)) return
|
|
131
|
+
|
|
132
|
+
const aggregateKey = getCellKey(xValue, seriesEntry.dataKey)
|
|
133
|
+
const existing = aggregated.get(aggregateKey)
|
|
134
|
+
|
|
135
|
+
if (existing) {
|
|
136
|
+
existing.value = (existing.value || 0) + numericValue
|
|
137
|
+
existing.sourceRows.push(row)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
aggregated.set(aggregateKey, {
|
|
142
|
+
rowKey: seriesEntry.dataKey,
|
|
143
|
+
rowLabel: getSeriesLabel(seriesEntry, seriesLabels),
|
|
144
|
+
xValue,
|
|
145
|
+
xLabel: toDisplayLabel(xValue),
|
|
146
|
+
value: numericValue,
|
|
147
|
+
sourceRows: [row]
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const orderedSeries = series.map(seriesEntry => ({
|
|
153
|
+
rowKey: seriesEntry.dataKey,
|
|
154
|
+
rowLabel: getSeriesLabel(seriesEntry, seriesLabels)
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
const columns = orderedXValues.map(xValue => {
|
|
158
|
+
const bins = orderedSeries.map(({ rowKey, rowLabel }) => {
|
|
159
|
+
const aggregateKey = getCellKey(xValue, rowKey)
|
|
160
|
+
return aggregated.get(aggregateKey) || createEmptyCell(xValue, rowKey, rowLabel)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
key: String(xValue),
|
|
165
|
+
label: toDisplayLabel(xValue),
|
|
166
|
+
rawValue: xValue,
|
|
167
|
+
bins
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const { minValue, maxValue } = getHeatMapValueDomain(aggregated)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
columns,
|
|
175
|
+
rowLabels: orderedSeries.map(item => item.rowLabel),
|
|
176
|
+
minValue,
|
|
177
|
+
maxValue
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const getHeatMapPalette = (config: ChartConfig): string[] => {
|
|
182
|
+
const colorPalettes = filterChartColorPalettes(config)
|
|
183
|
+
const configPalette = config.general?.palette?.name
|
|
184
|
+
const migratedPaletteName = configPalette || getFallbackColorPalette(config)
|
|
185
|
+
const isReversedPalette = migratedPaletteName?.endsWith('reverse')
|
|
186
|
+
const basePaletteName = isReversedPalette ? migratedPaletteName.slice(0, -7) : migratedPaletteName
|
|
187
|
+
|
|
188
|
+
let palette =
|
|
189
|
+
config.general?.palette?.customColors ||
|
|
190
|
+
colorPalettes[migratePaletteWithMap(basePaletteName, paletteMigrationMap, false)] ||
|
|
191
|
+
colorPalettes[basePaletteName] ||
|
|
192
|
+
colorPalettes[configPalette]
|
|
193
|
+
|
|
194
|
+
if (!palette || palette.length < 2) {
|
|
195
|
+
palette = DEFAULT_HEATMAP_PALETTE
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const shouldReverse = config.general?.palette?.isReversed || isReversedPalette
|
|
199
|
+
return shouldReverse ? [...palette].reverse() : palette
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const getHeatMapColorBucketCount = (config: ChartConfig) => {
|
|
203
|
+
const numericValue = Number(config.heatmap?.colorBucketCount)
|
|
204
|
+
|
|
205
|
+
if (!Number.isFinite(numericValue)) return HEATMAP_CONFIG_DEFAULTS.colorBucketCount
|
|
206
|
+
|
|
207
|
+
return Math.min(Math.max(Math.round(numericValue), MIN_HEATMAP_COLOR_BUCKETS), MAX_HEATMAP_COLOR_BUCKETS)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const getHeatMapBucketPalette = (config: ChartConfig): string[] => {
|
|
211
|
+
const palette = getHeatMapPalette(config)
|
|
212
|
+
const bucketCount = getHeatMapColorBucketCount(config)
|
|
213
|
+
|
|
214
|
+
const interpolate = interpolateRgbBasis(palette)
|
|
215
|
+
return Array.from({ length: bucketCount }, (_, index) => {
|
|
216
|
+
return interpolate(bucketCount === 1 ? 0.5 : index / (bucketCount - 1))
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const getCleanRangeBoundary = (value: number) => Number(value.toFixed(12))
|
|
221
|
+
|
|
222
|
+
const getFormattedRangeValue = (value: number, formatNumber?: FormatLegendValueFn) => {
|
|
223
|
+
const cleanValue = getCleanRangeBoundary(value)
|
|
224
|
+
return typeof formatNumber === 'function' ? String(formatNumber(cleanValue, 'left')) : String(cleanValue)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const getHeatMapDataGroupRanges = (
|
|
228
|
+
config: ChartConfig,
|
|
229
|
+
minValue: number,
|
|
230
|
+
maxValue: number,
|
|
231
|
+
formatNumber?: FormatLegendValueFn
|
|
232
|
+
): HeatMapDataGroupRange[] => {
|
|
233
|
+
if (maxValue <= minValue) {
|
|
234
|
+
const value = getCleanRangeBoundary(minValue)
|
|
235
|
+
return [{ min: value, max: value, label: getFormattedRangeValue(value, formatNumber) }]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const bucketCount = getHeatMapColorBucketCount(config)
|
|
239
|
+
const rangeSize = (maxValue - minValue) / bucketCount
|
|
240
|
+
|
|
241
|
+
return Array.from({ length: bucketCount }, (_, index) => {
|
|
242
|
+
const min = getCleanRangeBoundary(minValue + index * rangeSize)
|
|
243
|
+
const max = getCleanRangeBoundary(index === bucketCount - 1 ? maxValue : minValue + (index + 1) * rangeSize)
|
|
244
|
+
const formattedMin = getFormattedRangeValue(min, formatNumber)
|
|
245
|
+
const formattedMax = getFormattedRangeValue(max, formatNumber)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
min,
|
|
249
|
+
max,
|
|
250
|
+
label: min === max ? formattedMin : `${formattedMin}\u2013${formattedMax}`
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const getHeatMapColorScale = (config: ChartConfig, minValue: number, maxValue: number) => {
|
|
256
|
+
const bucketCount = getHeatMapColorBucketCount(config)
|
|
257
|
+
const palette = getHeatMapBucketPalette(config)
|
|
258
|
+
const domainMax = minValue === maxValue ? maxValue + 1 : maxValue
|
|
259
|
+
|
|
260
|
+
return scaleQuantize<string>().domain([minValue, domainMax]).range(palette)
|
|
261
|
+
}
|