@cdc/chart 4.26.3 → 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 +489 -0
- package/README.md +42 -17
- package/dist/cdcchart.js +39024 -36587
- package/examples/feature/bar/additional-column-tooltip.json +3 -3
- package/examples/feature/deviation/planet-deviation-config.json +2 -2
- 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/feature/paired-bar/paired-bar-example.json +2 -2
- package/examples/feature/tests-case-rate/case-rate-example-config.json +2 -2
- package/examples/feature/tests-covid/covid-confidence-example-config.json +2 -2
- package/examples/feature/tests-covid/covid-example-config.json +2 -2
- package/examples/feature/tests-cutoff/cutoff-example-config.json +2 -2
- package/examples/feature/tests-non-numerics/planet-pie-example-config-nonnumeric.json +2 -2
- package/examples/metadata-variables.json +1 -1
- package/examples/minimal-example.json +24 -0
- package/examples/private/bump-chart.json +1116 -0
- package/examples/private/test-123.json +624 -0
- package/examples/private/test2.json +35012 -0
- package/package.json +5 -4
- package/src/CdcChartComponent.tsx +144 -60
- package/src/_stories/Chart.AltText.stories.tsx +136 -0
- package/src/_stories/{Chart.HTMLInDataTable.stories.tsx → Chart.HTMLInDataTable.smoke.stories.tsx} +10 -0
- package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
- package/src/_stories/Chart.Minimal.smoke.stories.tsx +25 -0
- package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
- package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +108 -0
- package/src/_stories/{Chart.stories.tsx → Chart.smoke.stories.tsx} +12 -0
- package/src/_stories/{Chart.tooltip.stories.tsx → Chart.tooltip.smoke.stories.tsx} +68 -0
- package/src/_stories/{ChartAxisTitles.stories.tsx → ChartAxisTitles.smoke.stories.tsx} +13 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +151 -85
- package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
- package/src/_stories/{ChartBrush.stories.tsx → ChartBrush.smoke.stories.tsx} +13 -4
- package/src/_stories/ChartEditor.Editor.stories.tsx +23 -0
- package/src/_stories/ChartLine.DashedDates.smoke.stories.tsx +23 -0
- 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/line_chart_dashed_dates.json +276 -0
- package/src/_stories/_mock/paired-bar-abbr.json +1 -1
- package/src/_stories/_mock/paired-bar.json +1 -1
- 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 +2 -2
- package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
- package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -2
- package/src/components/Axis/BottomAxis.tsx +1 -1
- package/src/components/Axis/LeftAxis.tsx +15 -14
- package/src/components/Axis/LeftAxisGridlines.tsx +14 -13
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +9 -5
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +9 -5
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +9 -5
- package/src/components/BarChart/components/BarChart.Vertical.tsx +9 -6
- package/src/components/BarChart/components/BarChart.tsx +2 -6
- package/src/components/BarChart/helpers/useBarChart.ts +1 -1
- package/src/components/BoxPlot/BoxPlot.Vertical.tsx +3 -3
- 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 +946 -960
- package/src/components/EditorPanel/EditorPanelContext.ts +1 -1
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +229 -165
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +169 -116
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +159 -109
- package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +3 -3
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +14 -28
- 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 +32 -21
- package/src/components/Forecasting/Forecasting.tsx +2 -2
- 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/HorizonChart/HorizonChart.tsx +10 -2
- package/src/components/HoverLine/HoverLine.tsx +6 -8
- package/src/components/Legend/Legend.Component.tsx +4 -4
- package/src/components/Legend/LegendGroup/LegendGroup.styles.css +5 -0
- package/src/components/Legend/LegendGroup/LegendGroup.tsx +3 -1
- package/src/components/Legend/helpers/index.ts +0 -2
- package/src/components/LineChart/LineChartProps.ts +1 -0
- package/src/components/LineChart/components/LineChart.BumpCircle.tsx +22 -24
- package/src/components/LineChart/helpers.test.ts +345 -0
- package/src/components/LineChart/helpers.ts +15 -5
- package/src/components/LineChart/index.tsx +18 -5
- package/src/components/LinearChart/VisualizationRenderer.tsx +11 -1
- package/src/components/LinearChart/tests/LinearChart.test.tsx +228 -7
- package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
- package/src/components/LinearChart.tsx +155 -77
- package/src/components/PairedBarChart.jsx +2 -2
- package/src/components/PieChart/PieChart.tsx +15 -10
- package/src/components/RadarChart/RadarChart.tsx +3 -1
- package/src/components/Regions/components/Regions.tsx +199 -35
- package/src/components/Sankey/components/Sankey.tsx +7 -3
- package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
- package/src/components/ScatterPlot/ScatterPlot.jsx +19 -17
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +31 -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 +5 -10
- package/src/components/WarmingStripes/WarmingStripes.tsx +5 -3
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
- package/src/data/initial-state.js +12 -0
- package/src/helpers/axisLabelFontSize.ts +8 -0
- package/src/helpers/bumpChartTooltip.ts +54 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +197 -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/helpers/tests/tooltipHelpers.test.ts +151 -0
- package/src/helpers/tooltipHelpers.ts +176 -0
- package/src/hooks/tests/useScales.test.tsx +105 -0
- package/src/hooks/useHighlightedBars.ts +9 -16
- package/src/hooks/useProgrammaticTooltip.ts +16 -2
- package/src/hooks/useScales.ts +19 -9
- package/src/hooks/useSmallMultipleSynchronization.ts +3 -4
- package/src/hooks/useTooltip.tsx +167 -38
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +32 -49
- package/src/test/CdcChart.test.jsx +24 -0
- package/src/types/ChartConfig.ts +19 -0
- package/src/types/ChartContext.ts +2 -0
- package/examples/chart-regression-2.json +0 -2360
- package/examples/feature/__data__/age-adjusted-rates.json +0 -1486
- package/examples/feature/__data__/city-temperature.json +0 -2198
- package/examples/feature/bar/Barchart_with_negative.json +0 -34
- package/examples/feature/boxplot/box-plot-data.json +0 -71
- package/examples/feature/boxplot/boxplot-data.json +0 -156
- package/examples/feature/combo/combochart-categories_are_numbers .json +0 -18
- package/examples/feature/forest-plot/log.json +0 -26
- package/examples/feature/tests-date-exclusions/date-exclusions-data.json +0 -162
- package/examples/region-issue.json +0 -2065
- package/examples/xaxis.json +0 -493
- package/src/_stories/Chart.SmallMultiples.stories.tsx +0 -63
- /package/examples/{data → __data__}/data-with-metadata.json +0 -0
- /package/examples/feature/bar/{tall-data.json → __data__/tall-data.json} +0 -0
- /package/examples/feature/deviation/{planet-deviation-data.json → __data__/planet-deviation-data.json} +0 -0
- /package/examples/feature/paired-bar/{paired-bar-data.json → __data__/paired-bar-data.json} +0 -0
- /package/examples/feature/paired-bar/{paired-bar-formatted.json → __data__/paired-bar-formatted.json} +0 -0
- /package/examples/feature/tests-case-rate/{case-rate-example-data.json → __data__/case-rate-example-data.json} +0 -0
- /package/examples/feature/tests-covid/{covid-example-data-confidence.json → __data__/covid-example-data-confidence.json} +0 -0
- /package/examples/feature/tests-covid/{covid-example-data.json → __data__/covid-example-data.json} +0 -0
- /package/examples/feature/tests-cutoff/{cutoff-example-data.json → __data__/cutoff-example-data.json} +0 -0
- /package/examples/feature/tests-non-numerics/{planet-example-data-nonnumeric.json → __data__/planet-example-data-nonnumeric.json} +0 -0
- /package/src/_stories/{Chart.Anchors.stories.tsx → Chart.Anchors.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.BoxPlot.stories.tsx → Chart.BoxPlot.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.CI.stories.tsx → Chart.CI.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Combo.stories.tsx → Chart.Combo.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.CustomColors.stories.tsx → Chart.CustomColors.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Defaults.stories.tsx → Chart.Defaults.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.DynamicSeries.stories.tsx → Chart.DynamicSeries.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Filters.stories.tsx → Chart.Filters.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Forecast.stories.tsx → Chart.Forecast.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Legend.Gradient.stories.tsx → Chart.Legend.Gradient.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Patterns.stories.tsx → Chart.Patterns.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.PreserveDecimals.stories.tsx → Chart.PreserveDecimals.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Regions.Categorical.stories.tsx → Chart.Regions.Categorical.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Regions.DateScale.stories.tsx → Chart.Regions.DateScale.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.Regions.DateTimeScale.stories.tsx → Chart.Regions.DateTimeScale.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.ScatterPlot.stories.tsx → Chart.ScatterPlot.smoke.stories.tsx} +0 -0
- /package/src/_stories/{Chart.SmallestLeftAxisMax.stories.tsx → Chart.SmallestLeftAxisMax.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartAnnotation.stories.tsx → ChartAnnotation.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartAxisLabels.stories.tsx → ChartAxisLabels.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartBrush.Matrix.Continuous.stories.tsx → ChartBrush.Matrix.Continuous.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartBrush.Matrix.Date.stories.tsx → ChartBrush.Matrix.Date.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartBrush.Matrix.DateTime.stories.tsx → ChartBrush.Matrix.DateTime.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartLine.QuadrantAngles.stories.tsx → ChartLine.QuadrantAngles.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartLine.Suppression.stories.tsx → ChartLine.Suppression.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartLine.Symbols.stories.tsx → ChartLine.Symbols.smoke.stories.tsx} +0 -0
- /package/src/_stories/{ChartPrefixSuffix.stories.tsx → ChartPrefixSuffix.smoke.stories.tsx} +0 -0
- /package/src/_stories/{TechAdoptionWithLinks.stories.tsx → TechAdoptionWithLinks.smoke.stories.tsx} +0 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import React, { useContext, useMemo } from 'react'
|
|
2
|
+
import { AxisBottom, AxisLeft, AxisTop } from '@visx/axis'
|
|
3
|
+
import { Group } from '@visx/group'
|
|
4
|
+
import { HeatmapRect } from '@visx/heatmap'
|
|
5
|
+
import { scaleBand } from '@visx/scale'
|
|
6
|
+
import { Tooltip as ReactTooltip } from 'react-tooltip'
|
|
7
|
+
import ConfigContext from '../../../ConfigContext'
|
|
8
|
+
import { buildHeatMapData, getHeatMapColorScale, HeatMapCell, HeatMapColumn } from '../helpers'
|
|
9
|
+
import { formatNumber as formatColumnNumber } from '@cdc/core/helpers/cove/number'
|
|
10
|
+
import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
|
|
11
|
+
import { type ChartConfig, type HeatMapXAxisPosition } from '../../../types/ChartConfig'
|
|
12
|
+
import { buildTooltipListHtml } from '../../../helpers/tooltipHelpers'
|
|
13
|
+
import { findColumnConfigByName, getSeriesColumnFormattingParams } from '../../../helpers/seriesColumnSettings'
|
|
14
|
+
import { HEATMAP_CONFIG_DEFAULTS } from '../heatmap.constants'
|
|
15
|
+
import './../heatmap.css'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* HeatMap render contract
|
|
19
|
+
*
|
|
20
|
+
* The chart treats `xAxis.dataKey` as the column bucket and `config.series` as the row definitions.
|
|
21
|
+
* Each configured series dataKey becomes one rendered row, and each distinct x value becomes one
|
|
22
|
+
* rendered column. The pure shaping and aggregation work lives in `../helpers/buildHeatMapData`;
|
|
23
|
+
* this component is responsible for formatting, layout, axes, accessibility labels, and tooltips.
|
|
24
|
+
*/
|
|
25
|
+
type HeatMapProps = {
|
|
26
|
+
parentWidth: number
|
|
27
|
+
parentHeight: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type TooltipColumn = {
|
|
31
|
+
label: string
|
|
32
|
+
name: string
|
|
33
|
+
options: {
|
|
34
|
+
addColPrefix?: string
|
|
35
|
+
addColSuffix?: string
|
|
36
|
+
addColRoundTo?: number
|
|
37
|
+
addColCommas?: boolean
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ParseDateFn = (value: string, showError?: boolean) => Date
|
|
42
|
+
type FormatDateFn = (value: Date) => string
|
|
43
|
+
type FormatValueFn = (value: number, position?: string) => string
|
|
44
|
+
type FormatCellValueFn = (cell: HeatMapCell) => string
|
|
45
|
+
|
|
46
|
+
type HeatMapMargins = {
|
|
47
|
+
top: number
|
|
48
|
+
right: number
|
|
49
|
+
bottom: number
|
|
50
|
+
left: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type HeatMapLayout = {
|
|
54
|
+
availableWidth: number
|
|
55
|
+
availableHeight: number
|
|
56
|
+
gridWidth: number
|
|
57
|
+
gridHeight: number
|
|
58
|
+
cellSize: number
|
|
59
|
+
xGroupOffset: number
|
|
60
|
+
xOffset: number
|
|
61
|
+
yOffset: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const AXIS_TOP_WITH_TICKS = 36
|
|
65
|
+
const AXIS_TOP_WITHOUT_TICKS = 24
|
|
66
|
+
const AXIS_TOP_LABEL_SPACE = 28
|
|
67
|
+
const AXIS_TICK_FONT_SIZE = 12
|
|
68
|
+
const AXIS_TITLE_SPACE = 30
|
|
69
|
+
const AXIS_TOP_TITLE_BASELINE = 18
|
|
70
|
+
const X_AXIS_TITLE_LABEL_SPACE = 32
|
|
71
|
+
const X_AXIS_TITLE_LABEL_PADDING = 9
|
|
72
|
+
const TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD = -50
|
|
73
|
+
const AXIS_MARGIN_PADDING = 12
|
|
74
|
+
const MIN_LEFT_MARGIN = 64
|
|
75
|
+
const MAX_LEFT_MARGIN = 240
|
|
76
|
+
const MIN_X_AXIS_MARGIN = 40
|
|
77
|
+
const MAX_X_AXIS_MARGIN = 220
|
|
78
|
+
const AXIS_TICK_FONT = `normal ${AXIS_TICK_FONT_SIZE}px Nunito, sans-serif`
|
|
79
|
+
const DEFAULT_SERIES_LABEL = 'Series'
|
|
80
|
+
const DEFAULT_VALUE_LABEL = 'Value'
|
|
81
|
+
const EMPTY_VALUE_LABEL = 'No data'
|
|
82
|
+
const MIN_CELL_VALUE_WIDTH = 18
|
|
83
|
+
const MIN_CELL_VALUE_HEIGHT = 14
|
|
84
|
+
const SIDE_Y_AXIS_TITLE_GAP = 20
|
|
85
|
+
|
|
86
|
+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
|
87
|
+
|
|
88
|
+
const getNonNegativeConfigNumber = (value: unknown, fallback: number) => {
|
|
89
|
+
const numericValue = Number(value)
|
|
90
|
+
return Number.isFinite(numericValue) ? Math.max(numericValue, 0) : fallback
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const getMeasuredTextWidth = (label: string) => getTextWidth(label, AXIS_TICK_FONT) || label.length * 7
|
|
94
|
+
|
|
95
|
+
const getWidestLabelWidth = (labels: string[]) =>
|
|
96
|
+
labels.reduce((widest, label) => Math.max(widest, getMeasuredTextWidth(String(label))), 0)
|
|
97
|
+
|
|
98
|
+
const getRotatedLabelHeight = (labels: string[], rotationDegrees: number) => {
|
|
99
|
+
if (labels.length === 0) return 0
|
|
100
|
+
|
|
101
|
+
const labelWidth = getWidestLabelWidth(labels)
|
|
102
|
+
const radians = (Math.abs(rotationDegrees) * Math.PI) / 180
|
|
103
|
+
|
|
104
|
+
return Math.ceil(Math.abs(Math.sin(radians)) * labelWidth + Math.abs(Math.cos(radians)) * AXIS_TICK_FONT_SIZE)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const getXAxisTitleDistance = (labels: string[], rotationDegrees: number, columnLabelGap: number) => {
|
|
108
|
+
const rotatedLabelHeight = getRotatedLabelHeight(labels, rotationDegrees)
|
|
109
|
+
return columnLabelGap + Math.max(rotatedLabelHeight + X_AXIS_TITLE_LABEL_PADDING, X_AXIS_TITLE_LABEL_SPACE)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const getAxisMarginMax = (parentWidth: number, fallbackMax: number) => {
|
|
113
|
+
if (parentWidth <= 0) return fallbackMax
|
|
114
|
+
|
|
115
|
+
return Math.max(MIN_LEFT_MARGIN, Math.min(fallbackMax, parentWidth * 0.45))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const getHeatMapXAxisPosition = (config: ChartConfig): HeatMapXAxisPosition =>
|
|
119
|
+
config.heatmap?.xAxisPosition === 'bottom' ? 'bottom' : HEATMAP_CONFIG_DEFAULTS.xAxisPosition
|
|
120
|
+
|
|
121
|
+
const getHeatMapYAxisLabel = (config: ChartConfig) =>
|
|
122
|
+
String(config.yAxis?.label || config.runtime?.yAxis?.label || '').trim()
|
|
123
|
+
|
|
124
|
+
const shouldRenderTopYAxisTitle = (config: ChartConfig) =>
|
|
125
|
+
config.yAxis?.titlePlacement === 'top' && !config.hideYAxisLabel && Boolean(getHeatMapYAxisLabel(config))
|
|
126
|
+
|
|
127
|
+
const getXAxisTickLabelProps = (xAxisPosition: HeatMapXAxisPosition, xTickRotation: number, columnLabelGap: number) => {
|
|
128
|
+
const isSteepRotation = xTickRotation < TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD
|
|
129
|
+
|
|
130
|
+
if (!xTickRotation) {
|
|
131
|
+
return {
|
|
132
|
+
fontSize: 12,
|
|
133
|
+
textAnchor: 'middle',
|
|
134
|
+
angle: 0,
|
|
135
|
+
dx: 0,
|
|
136
|
+
y: xAxisPosition === 'top' ? -columnLabelGap : columnLabelGap
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
fontSize: 12,
|
|
142
|
+
textAnchor: xAxisPosition === 'top' ? 'start' : 'end',
|
|
143
|
+
angle: xTickRotation,
|
|
144
|
+
dx: isSteepRotation || xAxisPosition === 'top' ? 0 : '-0.5em',
|
|
145
|
+
y: xAxisPosition === 'top' ? -columnLabelGap : columnLabelGap,
|
|
146
|
+
...(isSteepRotation ? { verticalAnchor: 'middle' as const } : {})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isDateAxisType = (axisType?: string) => ['date', 'date-time'].includes(axisType || '')
|
|
151
|
+
|
|
152
|
+
const parseRgbColor = (color?: string): [number, number, number] | null => {
|
|
153
|
+
if (!color) return null
|
|
154
|
+
|
|
155
|
+
const hexMatch = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
|
|
156
|
+
if (hexMatch) {
|
|
157
|
+
const hex = hexMatch[1]
|
|
158
|
+
const normalized =
|
|
159
|
+
hex.length === 3
|
|
160
|
+
? hex
|
|
161
|
+
.split('')
|
|
162
|
+
.map(character => `${character}${character}`)
|
|
163
|
+
.join('')
|
|
164
|
+
: hex
|
|
165
|
+
|
|
166
|
+
return [
|
|
167
|
+
parseInt(normalized.slice(0, 2), 16),
|
|
168
|
+
parseInt(normalized.slice(2, 4), 16),
|
|
169
|
+
parseInt(normalized.slice(4, 6), 16)
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
|
|
174
|
+
if (!rgbMatch) return null
|
|
175
|
+
|
|
176
|
+
return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const getRelativeLuminance = ([red, green, blue]: [number, number, number]) => {
|
|
180
|
+
const [r, g, b] = [red, green, blue].map(channel => {
|
|
181
|
+
const normalized = channel / 255
|
|
182
|
+
return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const getContrastRatio = (leftColor: [number, number, number], rightColor: [number, number, number]) => {
|
|
189
|
+
const left = getRelativeLuminance(leftColor)
|
|
190
|
+
const right = getRelativeLuminance(rightColor)
|
|
191
|
+
const lighter = Math.max(left, right)
|
|
192
|
+
const darker = Math.min(left, right)
|
|
193
|
+
|
|
194
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const getReadableCellTextColor = (fillColor?: string) => {
|
|
198
|
+
const rgb = parseRgbColor(fillColor)
|
|
199
|
+
if (!rgb) return '#1c1d1f'
|
|
200
|
+
|
|
201
|
+
return getContrastRatio(rgb, [28, 29, 31]) >= getContrastRatio(rgb, [255, 255, 255]) ? '#1c1d1f' : '#fff'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Axis labels and tooltip labels intentionally use separate lookups.
|
|
206
|
+
* The x-axis should follow the standard axis date formatter, while tooltips may use
|
|
207
|
+
* `config.tooltips.dateDisplayFormat` through `formatTooltipsDate`.
|
|
208
|
+
*/
|
|
209
|
+
const buildXLabelLookup = (
|
|
210
|
+
columns: HeatMapColumn[],
|
|
211
|
+
xAxisType: string | undefined,
|
|
212
|
+
formatDate: (value: Date) => string,
|
|
213
|
+
parseDate: (value: string, showError?: boolean) => Date
|
|
214
|
+
): Record<string, string> =>
|
|
215
|
+
Object.fromEntries(
|
|
216
|
+
columns.map(column => [
|
|
217
|
+
column.key,
|
|
218
|
+
isDateAxisType(xAxisType) ? formatDate(parseDate(String(column.rawValue), false)) : column.label
|
|
219
|
+
])
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Reserve enough space for row labels and the top axis without letting user-provided axis
|
|
224
|
+
* sizes create extreme blank areas. HeatMap rows are series labels, so the left margin is
|
|
225
|
+
* based on the longest rendered row label plus any configured y-axis size.
|
|
226
|
+
*/
|
|
227
|
+
const buildChartMargins = (
|
|
228
|
+
config: ChartConfig,
|
|
229
|
+
rowLabels: string[],
|
|
230
|
+
columnLabels: string[],
|
|
231
|
+
parentWidth: number
|
|
232
|
+
): HeatMapMargins => {
|
|
233
|
+
const rowLabelWidth = config.yAxis?.hideLabel ? 0 : getWidestLabelWidth(rowLabels)
|
|
234
|
+
const yAxisSize = Number(config.yAxis?.size) || 0
|
|
235
|
+
const xAxisSize = Number(config.xAxis?.size) || 0
|
|
236
|
+
const columnLabelGap = getNonNegativeConfigNumber(
|
|
237
|
+
config.heatmap?.columnLabelGap,
|
|
238
|
+
HEATMAP_CONFIG_DEFAULTS.columnLabelGap
|
|
239
|
+
)
|
|
240
|
+
const xAxisPosition = getHeatMapXAxisPosition(config)
|
|
241
|
+
const xTickRotation = getNonNegativeConfigNumber(config.xAxis?.tickRotation ?? config.xAxis?.maxTickRotation, 0)
|
|
242
|
+
const xTickLabelSpace = config.xAxis?.hideLabel
|
|
243
|
+
? 0
|
|
244
|
+
: columnLabelGap + getRotatedLabelHeight(columnLabels, xTickRotation)
|
|
245
|
+
const yAxisLabel = getHeatMapYAxisLabel(config)
|
|
246
|
+
const hasTopYAxisTitle = shouldRenderTopYAxisTitle(config)
|
|
247
|
+
const topYAxisTitleSpace = hasTopYAxisTitle ? AXIS_TOP_LABEL_SPACE : 0
|
|
248
|
+
const sideYAxisTitleSpace = !config.hideYAxisLabel && yAxisLabel && !hasTopYAxisTitle ? AXIS_TITLE_SPACE : 0
|
|
249
|
+
const left = clamp(
|
|
250
|
+
Math.max(yAxisSize, rowLabelWidth + sideYAxisTitleSpace + AXIS_MARGIN_PADDING, yAxisLabel ? 96 : 72),
|
|
251
|
+
MIN_LEFT_MARGIN,
|
|
252
|
+
getAxisMarginMax(parentWidth, MAX_LEFT_MARGIN)
|
|
253
|
+
)
|
|
254
|
+
const topBase = config.xAxis?.hideTicks ? AXIS_TOP_WITHOUT_TICKS : AXIS_TOP_WITH_TICKS
|
|
255
|
+
const topLabelSpace = !config.hideXAxisLabel && config.xAxis?.label ? AXIS_TOP_LABEL_SPACE : 0
|
|
256
|
+
const xAxisMargin = clamp(
|
|
257
|
+
Math.max(xAxisSize, xTickLabelSpace + topBase + topLabelSpace + AXIS_MARGIN_PADDING),
|
|
258
|
+
MIN_X_AXIS_MARGIN,
|
|
259
|
+
MAX_X_AXIS_MARGIN
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
top: (xAxisPosition === 'top' ? xAxisMargin : 24) + topYAxisTitleSpace,
|
|
264
|
+
right: 16,
|
|
265
|
+
bottom: xAxisPosition === 'bottom' ? xAxisMargin : 20,
|
|
266
|
+
left
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build a square-cell matrix that behaves like a heatmap, not a continuous x/y plot.
|
|
272
|
+
* The grid is centered as a complete block, while `xOffset` is the configured
|
|
273
|
+
* row-label-to-grid gap. Keeping that as an explicit HeatMap option makes visual
|
|
274
|
+
* tuning predictable when rows use long city, state, or category names.
|
|
275
|
+
*/
|
|
276
|
+
const buildGridLayout = (
|
|
277
|
+
parentWidth: number,
|
|
278
|
+
parentHeight: number,
|
|
279
|
+
margins: HeatMapMargins,
|
|
280
|
+
columnCount: number,
|
|
281
|
+
rowCount: number,
|
|
282
|
+
rowLabelGap: number
|
|
283
|
+
): HeatMapLayout => {
|
|
284
|
+
const availableWidth = Math.max(parentWidth - margins.left - margins.right, 0)
|
|
285
|
+
const availableHeight = Math.max(parentHeight - margins.top - margins.bottom, 0)
|
|
286
|
+
const columnBandWidth = availableWidth / Math.max(columnCount, 1)
|
|
287
|
+
const rowBandHeight = availableHeight / Math.max(rowCount, 1)
|
|
288
|
+
const cellSize = Math.min(columnBandWidth, rowBandHeight)
|
|
289
|
+
const gridWidth = cellSize * Math.max(columnCount, 1)
|
|
290
|
+
const gridHeight = cellSize * Math.max(rowCount, 1)
|
|
291
|
+
const xOffset = Math.min(rowLabelGap, Math.max(availableWidth - gridWidth, 0))
|
|
292
|
+
const xGroupOffset = Math.max((availableWidth - xOffset - gridWidth) / 2, 0)
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
availableWidth,
|
|
296
|
+
availableHeight,
|
|
297
|
+
gridWidth,
|
|
298
|
+
gridHeight,
|
|
299
|
+
cellSize,
|
|
300
|
+
xGroupOffset,
|
|
301
|
+
xOffset,
|
|
302
|
+
yOffset: 0
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Additional tooltip rows come from column configs with `tooltips: true`.
|
|
308
|
+
* The x-axis column and heatmap series columns are skipped because they are already
|
|
309
|
+
* represented by the tooltip heading, row label, and cell value.
|
|
310
|
+
*/
|
|
311
|
+
const getTooltipColumns = (
|
|
312
|
+
config: ChartConfig,
|
|
313
|
+
xDataKey: string | undefined,
|
|
314
|
+
heatMapSeries: { dataKey: string }[]
|
|
315
|
+
): TooltipColumn[] =>
|
|
316
|
+
Object.entries(config.columns || {}).reduce<TooltipColumn[]>((tooltipColumns, [_, value]) => {
|
|
317
|
+
if (!value.tooltips || !value.name) return tooltipColumns
|
|
318
|
+
if (value.name === xDataKey) return tooltipColumns
|
|
319
|
+
if (heatMapSeries.some(series => series.dataKey === value.name)) return tooltipColumns
|
|
320
|
+
|
|
321
|
+
tooltipColumns.push({
|
|
322
|
+
label: value.label || value.name,
|
|
323
|
+
name: value.name,
|
|
324
|
+
options: {
|
|
325
|
+
addColPrefix: value.prefix,
|
|
326
|
+
addColSuffix: value.suffix,
|
|
327
|
+
addColRoundTo: value.roundToPlace,
|
|
328
|
+
addColCommas: value.commas
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
return tooltipColumns
|
|
333
|
+
}, [])
|
|
334
|
+
|
|
335
|
+
const getFormattedValueText = (cell: HeatMapCell, formatCellValue: FormatCellValueFn) =>
|
|
336
|
+
cell.value === null ? EMPTY_VALUE_LABEL : formatCellValue(cell)
|
|
337
|
+
|
|
338
|
+
const getHeatMapSeriesLabel = (
|
|
339
|
+
config: ChartConfig,
|
|
340
|
+
seriesEntry: { dataKey: string; name?: string },
|
|
341
|
+
runtimeSeriesLabels?: Record<string, string>
|
|
342
|
+
) => {
|
|
343
|
+
const columnEntry = findColumnConfigByName(config.columns || {}, seriesEntry.dataKey)
|
|
344
|
+
const configuredColumnLabel = columnEntry?.columnConfig?.label
|
|
345
|
+
const hasCustomColumnLabel = configuredColumnLabel && configuredColumnLabel !== seriesEntry.dataKey
|
|
346
|
+
|
|
347
|
+
return hasCustomColumnLabel
|
|
348
|
+
? configuredColumnLabel
|
|
349
|
+
: runtimeSeriesLabels?.[seriesEntry.dataKey] || seriesEntry.name || seriesEntry.dataKey
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const getHeatMapSeriesLabels = (config: ChartConfig, heatMapSeries: { dataKey: string; name?: string }[]) =>
|
|
353
|
+
heatMapSeries.reduce<Record<string, string>>((seriesLabels, seriesEntry) => {
|
|
354
|
+
seriesLabels[seriesEntry.dataKey] = getHeatMapSeriesLabel(config, seriesEntry, config.runtime?.seriesLabels)
|
|
355
|
+
return seriesLabels
|
|
356
|
+
}, {})
|
|
357
|
+
|
|
358
|
+
const getHeatMapSeriesTooltipEnabled = (config: ChartConfig, seriesKey: string) =>
|
|
359
|
+
config.series?.find(series => series.dataKey === seriesKey)?.tooltip !== false
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Aggregated cells can represent multiple source rows. If all source rows share the same
|
|
363
|
+
* formatted additional-column value, show it once. If the source rows disagree, summarize
|
|
364
|
+
* that field as "Multiple values" instead of implying one row's value represents all rows.
|
|
365
|
+
*/
|
|
366
|
+
const formatAdditionalColumnValue = (cell: HeatMapCell, column: TooltipColumn, config: ChartConfig) => {
|
|
367
|
+
const formattedValues = cell.sourceRows
|
|
368
|
+
.map(sourceRow => sourceRow[column.name])
|
|
369
|
+
.filter(value => value !== undefined && value !== null && value !== '')
|
|
370
|
+
.map(value => String(formatColumnNumber(value, 'left', false, config as any, column.options as any)))
|
|
371
|
+
|
|
372
|
+
const uniqueValues = Array.from(new Set(formattedValues))
|
|
373
|
+
|
|
374
|
+
if (uniqueValues.length === 0) return EMPTY_VALUE_LABEL
|
|
375
|
+
if (uniqueValues.length > 1) return 'Multiple values'
|
|
376
|
+
return uniqueValues[0]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Each cell is keyboard-focusable, so it needs a concise label that matches the visual
|
|
381
|
+
* encoding: x bucket, row/series label, and formatted numeric value.
|
|
382
|
+
*/
|
|
383
|
+
const getHeatMapCellLabel = ({
|
|
384
|
+
cell,
|
|
385
|
+
config,
|
|
386
|
+
formatNumber,
|
|
387
|
+
xLabel,
|
|
388
|
+
xDataKey
|
|
389
|
+
}: {
|
|
390
|
+
cell: HeatMapCell
|
|
391
|
+
config: ChartConfig
|
|
392
|
+
formatNumber: FormatCellValueFn
|
|
393
|
+
xLabel: string
|
|
394
|
+
xDataKey?: string
|
|
395
|
+
}) => {
|
|
396
|
+
const xAxisLabel = config.xAxis?.label || xDataKey
|
|
397
|
+
const yAxisLabel = config.yAxis?.label || DEFAULT_SERIES_LABEL
|
|
398
|
+
const valueLabel = config.legend?.label || DEFAULT_VALUE_LABEL
|
|
399
|
+
|
|
400
|
+
return `${xAxisLabel}: ${xLabel}; ${yAxisLabel}: ${cell.rowLabel}; ${valueLabel}: ${getFormattedValueText(
|
|
401
|
+
cell,
|
|
402
|
+
formatNumber
|
|
403
|
+
)}`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Tooltip HTML uses the shared chart tooltip list helper to preserve consistent tooltip
|
|
408
|
+
* markup and styling across chart types. Empty cells still receive a tooltip so users can
|
|
409
|
+
* distinguish missing values from very low values.
|
|
410
|
+
*/
|
|
411
|
+
const buildTooltipHtml = ({
|
|
412
|
+
cell,
|
|
413
|
+
additionalColumns,
|
|
414
|
+
config,
|
|
415
|
+
formatNumber,
|
|
416
|
+
xLabel,
|
|
417
|
+
xDataKey,
|
|
418
|
+
showSeriesRows
|
|
419
|
+
}: {
|
|
420
|
+
cell: HeatMapCell
|
|
421
|
+
additionalColumns: TooltipColumn[]
|
|
422
|
+
config: ChartConfig
|
|
423
|
+
formatNumber: FormatCellValueFn
|
|
424
|
+
xLabel: string
|
|
425
|
+
xDataKey?: string
|
|
426
|
+
showSeriesRows: boolean
|
|
427
|
+
}) => {
|
|
428
|
+
const sourceRow = cell.sourceRows[0]
|
|
429
|
+
const xAxisLabel = config.xAxis?.label || xDataKey
|
|
430
|
+
const yAxisLabel = config.yAxis?.label || DEFAULT_SERIES_LABEL
|
|
431
|
+
const valueLabel = config.legend?.label || DEFAULT_VALUE_LABEL
|
|
432
|
+
|
|
433
|
+
const extraRows = sourceRow
|
|
434
|
+
? additionalColumns.map(column => ({
|
|
435
|
+
text: `${column.label}: ${formatAdditionalColumnValue(cell, column, config)}`
|
|
436
|
+
}))
|
|
437
|
+
: []
|
|
438
|
+
|
|
439
|
+
return buildTooltipListHtml({
|
|
440
|
+
heading: `${xAxisLabel}: ${xLabel}`,
|
|
441
|
+
bodyRows: [
|
|
442
|
+
...(showSeriesRows
|
|
443
|
+
? [
|
|
444
|
+
{ text: `${yAxisLabel}: ${cell.rowLabel}` },
|
|
445
|
+
{ text: `${valueLabel}: ${getFormattedValueText(cell, formatNumber)}` }
|
|
446
|
+
]
|
|
447
|
+
: []),
|
|
448
|
+
...(cell.sourceRows.length > 1 ? [{ text: `Aggregated Rows: ${String(cell.sourceRows.length)}` }] : []),
|
|
449
|
+
...extraRows
|
|
450
|
+
]
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
|
|
455
|
+
const {
|
|
456
|
+
config,
|
|
457
|
+
filteredData,
|
|
458
|
+
excludedData,
|
|
459
|
+
parseDate,
|
|
460
|
+
formatDate,
|
|
461
|
+
formatTooltipsDate,
|
|
462
|
+
formatNumber,
|
|
463
|
+
handleChartAriaLabels
|
|
464
|
+
} = useContext(ConfigContext)
|
|
465
|
+
|
|
466
|
+
const parseDateValue: ParseDateFn =
|
|
467
|
+
typeof parseDate === 'function'
|
|
468
|
+
? (value, showError = true) => parseDate(value, showError)
|
|
469
|
+
: value => new Date(value)
|
|
470
|
+
const formatDateValue: FormatDateFn =
|
|
471
|
+
typeof formatDate === 'function' ? value => formatDate(value) : value => value.toISOString()
|
|
472
|
+
const formatTooltipDateValue: FormatDateFn =
|
|
473
|
+
typeof formatTooltipsDate === 'function' && config.tooltips?.dateDisplayFormat
|
|
474
|
+
? value => formatTooltipsDate(value)
|
|
475
|
+
: formatDateValue
|
|
476
|
+
const formatNumericValue: FormatValueFn =
|
|
477
|
+
typeof formatNumber === 'function'
|
|
478
|
+
? (value, position = 'left') => String(formatNumber(value, position))
|
|
479
|
+
: value => String(value)
|
|
480
|
+
|
|
481
|
+
const rows = filteredData?.length ? filteredData : excludedData || []
|
|
482
|
+
const xDataKey = config.xAxis?.dataKey
|
|
483
|
+
const heatMapSeries = config.series || []
|
|
484
|
+
const heatMapSeriesLabels = useMemo(() => getHeatMapSeriesLabels(config, heatMapSeries), [config, heatMapSeries])
|
|
485
|
+
|
|
486
|
+
// Transform raw filtered rows into the matrix shape expected by @visx/heatmap.
|
|
487
|
+
const { columns, rowLabels, minValue, maxValue } = useMemo(
|
|
488
|
+
() =>
|
|
489
|
+
buildHeatMapData({
|
|
490
|
+
data: rows as Record<string, any>[],
|
|
491
|
+
xDataKey,
|
|
492
|
+
series: heatMapSeries,
|
|
493
|
+
seriesLabels: heatMapSeriesLabels,
|
|
494
|
+
xAxisType: config.xAxis?.type,
|
|
495
|
+
parseDate: parseDateValue
|
|
496
|
+
}),
|
|
497
|
+
[rows, xDataKey, heatMapSeries, heatMapSeriesLabels, config.xAxis?.type, parseDateValue]
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
// Axis and tooltip date labels can intentionally differ, so keep the two maps separate.
|
|
501
|
+
const xLabelLookup = useMemo(
|
|
502
|
+
() => buildXLabelLookup(columns, config.xAxis?.type, formatDateValue, parseDateValue),
|
|
503
|
+
[columns, config.xAxis?.type, formatDateValue, parseDateValue]
|
|
504
|
+
)
|
|
505
|
+
const xAxisLabels = useMemo(
|
|
506
|
+
() => columns.map(column => xLabelLookup[String(column.rawValue)] || column.label),
|
|
507
|
+
[columns, xLabelLookup]
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
const tooltipXLabelLookup = useMemo(
|
|
511
|
+
() => buildXLabelLookup(columns, config.xAxis?.type, formatTooltipDateValue, parseDateValue),
|
|
512
|
+
[columns, config.xAxis?.type, formatTooltipDateValue, parseDateValue]
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
const margins = useMemo(
|
|
516
|
+
() => buildChartMargins(config, rowLabels, xAxisLabels, parentWidth),
|
|
517
|
+
[config, rowLabels, xAxisLabels, parentWidth]
|
|
518
|
+
)
|
|
519
|
+
const configuredRowLabelGap = getNonNegativeConfigNumber(
|
|
520
|
+
config.heatmap?.rowLabelGap,
|
|
521
|
+
HEATMAP_CONFIG_DEFAULTS.rowLabelGap
|
|
522
|
+
)
|
|
523
|
+
const rowLabelGap = config.yAxis?.hideLabel ? 0 : configuredRowLabelGap
|
|
524
|
+
const columnLabelGap = getNonNegativeConfigNumber(
|
|
525
|
+
config.heatmap?.columnLabelGap,
|
|
526
|
+
HEATMAP_CONFIG_DEFAULTS.columnLabelGap
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
const columnCount = Math.max(columns.length, 1)
|
|
530
|
+
const rowCount = Math.max(rowLabels.length, 1)
|
|
531
|
+
const { gridWidth, gridHeight, cellSize, xGroupOffset, xOffset, yOffset } = useMemo(
|
|
532
|
+
() => buildGridLayout(parentWidth, parentHeight, margins, columnCount, rowCount, rowLabelGap),
|
|
533
|
+
[parentWidth, parentHeight, margins, columnCount, rowCount, rowLabelGap]
|
|
534
|
+
)
|
|
535
|
+
const cellGap = getNonNegativeConfigNumber(config.heatmap?.cellPadding, HEATMAP_CONFIG_DEFAULTS.cellPadding)
|
|
536
|
+
// During responsive measurement, cellSize can briefly be 0. Clamp the effective gap so SVG rects never go negative.
|
|
537
|
+
const effectiveCellGap = Math.min(cellGap, Math.max(cellSize - 1, 0))
|
|
538
|
+
|
|
539
|
+
const xAxisScale = useMemo(
|
|
540
|
+
() =>
|
|
541
|
+
scaleBand<string>({
|
|
542
|
+
domain: columns.map(column => column.key),
|
|
543
|
+
range: [xOffset, xOffset + gridWidth],
|
|
544
|
+
padding: 0
|
|
545
|
+
}),
|
|
546
|
+
[columns, xOffset, gridWidth]
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
const yAxisScale = useMemo(
|
|
550
|
+
() =>
|
|
551
|
+
scaleBand<string>({
|
|
552
|
+
domain: rowLabels,
|
|
553
|
+
range: [yOffset, yOffset + gridHeight],
|
|
554
|
+
padding: 0
|
|
555
|
+
}),
|
|
556
|
+
[rowLabels, yOffset, gridHeight]
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
const heatMapColorScale = useMemo(
|
|
560
|
+
() => getHeatMapColorScale(config, minValue, maxValue),
|
|
561
|
+
[config, minValue, maxValue]
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
const additionalColumns = useMemo(
|
|
565
|
+
() => getTooltipColumns(config, xDataKey, heatMapSeries),
|
|
566
|
+
[config, xDataKey, heatMapSeries]
|
|
567
|
+
)
|
|
568
|
+
const formatHeatMapCellValue: FormatCellValueFn = cell => {
|
|
569
|
+
if (cell.value === null) return EMPTY_VALUE_LABEL
|
|
570
|
+
|
|
571
|
+
const columnConfig = findColumnConfigByName(config.columns || {}, cell.rowKey)?.columnConfig
|
|
572
|
+
const formattingParams = getSeriesColumnFormattingParams(columnConfig)
|
|
573
|
+
|
|
574
|
+
if (formattingParams) {
|
|
575
|
+
return String(formatColumnNumber(String(cell.value), 'left', false, config as any, formattingParams as any))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return formatNumericValue(cell.value, 'left')
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const tooltipId = `cdc-open-viz-tooltip-${config.runtime.uniqueId}`
|
|
582
|
+
const xAxisLabel = config.xAxis?.label
|
|
583
|
+
const yAxisLabel = getHeatMapYAxisLabel(config)
|
|
584
|
+
const xAxisPosition = getHeatMapXAxisPosition(config)
|
|
585
|
+
const xTickRotation = -getNonNegativeConfigNumber(config.xAxis?.tickRotation ?? config.xAxis?.maxTickRotation, 0)
|
|
586
|
+
const yTickRotation = -getNonNegativeConfigNumber(config.yAxis?.tickRotation, 0)
|
|
587
|
+
const showCellValues = Boolean(config.heatmap?.showCellValues)
|
|
588
|
+
const showTopYAxisTitle = shouldRenderTopYAxisTitle(config)
|
|
589
|
+
const visibleXAxisLabels = config.xAxis?.hideLabel ? [] : xAxisLabels
|
|
590
|
+
const xAxisTitleDistance = getXAxisTitleDistance(visibleXAxisLabels, Math.abs(xTickRotation), columnLabelGap)
|
|
591
|
+
const rowLabelTitleX = config.yAxis?.hideLabel ? 0 : -getWidestLabelWidth(rowLabels)
|
|
592
|
+
const sideYAxisTitleX = -Math.max(Math.abs(rowLabelTitleX) + SIDE_Y_AXIS_TITLE_GAP, AXIS_TITLE_SPACE)
|
|
593
|
+
const hasXAxisTitle = !config.hideXAxisLabel && Boolean(xAxisLabel)
|
|
594
|
+
const topYAxisTitleY = xAxisPosition === 'top' && hasXAxisTitle ? -xAxisTitleDistance : -AXIS_TOP_TITLE_BASELINE
|
|
595
|
+
const renderXAxisTitle = () => {
|
|
596
|
+
if (config.hideXAxisLabel || !xAxisLabel) return null
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<text
|
|
600
|
+
className='cdc-heatmap__axis-title'
|
|
601
|
+
x={xOffset + gridWidth / 2}
|
|
602
|
+
y={xAxisPosition === 'top' ? -xAxisTitleDistance : gridHeight + xAxisTitleDistance}
|
|
603
|
+
textAnchor='middle'
|
|
604
|
+
>
|
|
605
|
+
{xAxisLabel}
|
|
606
|
+
</text>
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
const renderYAxisTitle = () => {
|
|
610
|
+
if (!yAxisLabel || config.hideYAxisLabel) return null
|
|
611
|
+
|
|
612
|
+
if (showTopYAxisTitle) {
|
|
613
|
+
return (
|
|
614
|
+
<text className='cdc-heatmap__axis-title' x={rowLabelTitleX} y={topYAxisTitleY} textAnchor='start'>
|
|
615
|
+
{yAxisLabel}
|
|
616
|
+
</text>
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<text
|
|
622
|
+
className='cdc-heatmap__axis-title'
|
|
623
|
+
transform={`translate(${sideYAxisTitleX}, ${yOffset + gridHeight / 2}) rotate(-90)`}
|
|
624
|
+
textAnchor='middle'
|
|
625
|
+
>
|
|
626
|
+
{yAxisLabel}
|
|
627
|
+
</text>
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return (
|
|
632
|
+
<div className='cdc-heatmap'>
|
|
633
|
+
<svg
|
|
634
|
+
width={parentWidth}
|
|
635
|
+
height={parentHeight}
|
|
636
|
+
aria-label={handleChartAriaLabels(config)}
|
|
637
|
+
className={`cdc-heatmap__svg${config.animate ? ' animated' : ''}`}
|
|
638
|
+
>
|
|
639
|
+
{/* Shift the complete heatmap block, not just the cells, so row labels stay close while the block stays centered. */}
|
|
640
|
+
<Group className='cdc-heatmap__plot' top={margins.top} left={margins.left + xGroupOffset}>
|
|
641
|
+
<HeatmapRect
|
|
642
|
+
data={columns}
|
|
643
|
+
xScale={columnIndex => columnIndex * cellSize + xOffset}
|
|
644
|
+
yScale={rowIndex => rowIndex * cellSize + yOffset}
|
|
645
|
+
binWidth={cellSize}
|
|
646
|
+
binHeight={cellSize}
|
|
647
|
+
gap={effectiveCellGap}
|
|
648
|
+
bins={column => column.bins}
|
|
649
|
+
count={bin => (typeof bin.value === 'number' ? bin.value : 0)}
|
|
650
|
+
>
|
|
651
|
+
{heatmap =>
|
|
652
|
+
heatmap.map(columnBins =>
|
|
653
|
+
columnBins.map(bin => {
|
|
654
|
+
const hasValue = typeof bin.bin.value === 'number'
|
|
655
|
+
const xLabel = tooltipXLabelLookup[String(bin.bin.xValue)] || bin.bin.xLabel
|
|
656
|
+
const cellWidth = Math.max(bin.width, 0)
|
|
657
|
+
const cellHeight = Math.max(bin.height, 0)
|
|
658
|
+
const fillColor = hasValue ? heatMapColorScale(bin.bin.value as number) : undefined
|
|
659
|
+
const shouldShowCellValue =
|
|
660
|
+
showCellValues &&
|
|
661
|
+
hasValue &&
|
|
662
|
+
cellWidth >= MIN_CELL_VALUE_WIDTH &&
|
|
663
|
+
cellHeight >= MIN_CELL_VALUE_HEIGHT
|
|
664
|
+
return (
|
|
665
|
+
<React.Fragment key={`heatmap-cell-${bin.column}-${bin.row}`}>
|
|
666
|
+
<rect
|
|
667
|
+
className={`visx-heatmap-rect cdc-heatmap__cell${hasValue ? '' : ' cdc-heatmap__cell--empty'}`}
|
|
668
|
+
role='img'
|
|
669
|
+
tabIndex={0}
|
|
670
|
+
aria-label={getHeatMapCellLabel({
|
|
671
|
+
cell: bin.bin,
|
|
672
|
+
config,
|
|
673
|
+
formatNumber: formatHeatMapCellValue,
|
|
674
|
+
xLabel,
|
|
675
|
+
xDataKey
|
|
676
|
+
})}
|
|
677
|
+
x={bin.x}
|
|
678
|
+
y={bin.y}
|
|
679
|
+
width={cellWidth}
|
|
680
|
+
height={cellHeight}
|
|
681
|
+
fill={fillColor}
|
|
682
|
+
data-tooltip-id={tooltipId}
|
|
683
|
+
data-tooltip-html={buildTooltipHtml({
|
|
684
|
+
cell: bin.bin,
|
|
685
|
+
additionalColumns,
|
|
686
|
+
config,
|
|
687
|
+
formatNumber: formatHeatMapCellValue,
|
|
688
|
+
xLabel,
|
|
689
|
+
xDataKey,
|
|
690
|
+
showSeriesRows: getHeatMapSeriesTooltipEnabled(config, bin.bin.rowKey)
|
|
691
|
+
})}
|
|
692
|
+
/>
|
|
693
|
+
{shouldShowCellValue && (
|
|
694
|
+
<text
|
|
695
|
+
className='cdc-heatmap__cell-value'
|
|
696
|
+
x={bin.x + cellWidth / 2}
|
|
697
|
+
y={bin.y + cellHeight / 2}
|
|
698
|
+
fill={getReadableCellTextColor(fillColor)}
|
|
699
|
+
fontSize={Math.max(9, Math.min(12, cellHeight * 0.35))}
|
|
700
|
+
textAnchor='middle'
|
|
701
|
+
dominantBaseline='middle'
|
|
702
|
+
aria-hidden='true'
|
|
703
|
+
>
|
|
704
|
+
{getFormattedValueText(bin.bin, formatHeatMapCellValue)}
|
|
705
|
+
</text>
|
|
706
|
+
)}
|
|
707
|
+
</React.Fragment>
|
|
708
|
+
)
|
|
709
|
+
})
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
</HeatmapRect>
|
|
713
|
+
|
|
714
|
+
{xAxisPosition === 'top' ? (
|
|
715
|
+
<AxisTop
|
|
716
|
+
scale={xAxisScale}
|
|
717
|
+
tickFormat={value => (config.xAxis?.hideLabel ? '' : xLabelLookup[String(value)] || String(value))}
|
|
718
|
+
hideAxisLine={Boolean(config.xAxis?.hideAxis)}
|
|
719
|
+
hideTicks={Boolean(config.xAxis?.hideTicks)}
|
|
720
|
+
hideZero={true}
|
|
721
|
+
tickLabelProps={() => getXAxisTickLabelProps(xAxisPosition, xTickRotation, columnLabelGap)}
|
|
722
|
+
/>
|
|
723
|
+
) : (
|
|
724
|
+
<AxisBottom
|
|
725
|
+
top={gridHeight}
|
|
726
|
+
scale={xAxisScale}
|
|
727
|
+
tickFormat={value => (config.xAxis?.hideLabel ? '' : xLabelLookup[String(value)] || String(value))}
|
|
728
|
+
hideAxisLine={Boolean(config.xAxis?.hideAxis)}
|
|
729
|
+
hideTicks={Boolean(config.xAxis?.hideTicks)}
|
|
730
|
+
hideZero={true}
|
|
731
|
+
tickLabelProps={() => getXAxisTickLabelProps(xAxisPosition, xTickRotation, columnLabelGap)}
|
|
732
|
+
/>
|
|
733
|
+
)}
|
|
734
|
+
<AxisLeft
|
|
735
|
+
scale={yAxisScale}
|
|
736
|
+
tickFormat={value => (config.yAxis?.hideLabel ? '' : value)}
|
|
737
|
+
hideAxisLine={Boolean(config.yAxis?.hideAxis)}
|
|
738
|
+
hideTicks={Boolean(config.yAxis?.hideTicks)}
|
|
739
|
+
tickLabelProps={() => ({
|
|
740
|
+
fontSize: 12,
|
|
741
|
+
textAnchor: 'end',
|
|
742
|
+
angle: yTickRotation,
|
|
743
|
+
dx: yTickRotation ? '-0.25em' : 0,
|
|
744
|
+
dy: '0.33em'
|
|
745
|
+
})}
|
|
746
|
+
/>
|
|
747
|
+
|
|
748
|
+
{renderXAxisTitle()}
|
|
749
|
+
{renderYAxisTitle()}
|
|
750
|
+
</Group>
|
|
751
|
+
</svg>
|
|
752
|
+
<ReactTooltip
|
|
753
|
+
id={tooltipId}
|
|
754
|
+
variant='light'
|
|
755
|
+
className='tooltip'
|
|
756
|
+
style={{ background: `rgba(255,255,255, ${config.tooltips.opacity / 100})`, color: 'black' }}
|
|
757
|
+
/>
|
|
758
|
+
</div>
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export default HeatMap
|