@cdc/chart 4.26.1 → 4.26.2
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/CLAUDE.local.md +79 -0
- package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
- package/dist/cdcchart.js +45357 -43655
- package/examples/default.json +378 -0
- package/examples/feature/__data__/horizon-chart-data.json +373 -0
- package/examples/feature/annotations/index.json +3 -6
- package/examples/feature/horizon/horizon-chart.json +395 -0
- package/examples/line-chart-states.json +1085 -0
- package/examples/private/123.json +694 -0
- package/examples/private/anchor-issue.json +4094 -0
- package/examples/private/backwards-slider.json +10430 -0
- package/examples/private/georgia.csv +160 -0
- package/examples/private/timeline-data.json +1 -0
- package/examples/private/timeline.json +389 -0
- package/examples/radar-chart-simple.json +133 -0
- package/examples/radar-chart.json +148 -0
- package/index.html +1 -31
- package/package.json +57 -59
- package/src/CdcChartComponent.tsx +99 -18
- package/src/_stories/Chart.Anchors.stories.tsx +10 -0
- package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
- package/src/_stories/Chart.CI.stories.tsx +13 -0
- package/src/_stories/Chart.Combo.stories.tsx +17 -0
- package/src/_stories/Chart.CustomColors.stories.tsx +4 -0
- package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
- package/src/_stories/Chart.Filters.stories.tsx +4 -0
- package/src/_stories/Chart.Forecast.stories.tsx +4 -0
- package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
- package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
- package/src/_stories/Chart.Patterns.stories.tsx +4 -0
- package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
- package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
- package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
- package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
- package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
- package/src/_stories/Chart.stories.tsx +37 -0
- package/src/_stories/Chart.tooltip.stories.tsx +7 -0
- package/src/_stories/ChartAnnotation.stories.tsx +10 -0
- package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
- package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
- package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
- package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
- package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
- package/src/_stories/ChartBrush.stories.tsx +7 -0
- package/src/_stories/ChartEditor.stories.tsx +7 -0
- package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
- package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
- package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
- package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
- package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
- package/src/_stories/_mock/brush_continuous.json +86 -0
- package/src/_stories/_mock/brush_date_large.json +176 -0
- package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
- package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
- package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +11 -17
- package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
- package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
- package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
- package/src/components/Annotations/components/AnnotationList.styles.css +4 -10
- package/src/components/Annotations/components/AnnotationList.tsx +5 -4
- package/src/components/Annotations/components/findNearestDatum.ts +75 -85
- package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
- package/src/components/Axis/BottomAxis.tsx +270 -0
- package/src/components/Axis/LeftAxis.tsx +404 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
- package/src/components/Axis/PairedBarAxis.tsx +186 -0
- package/src/components/Axis/README.md +94 -0
- package/src/components/Axis/RightAxis.tsx +108 -0
- package/src/components/Axis/axis.constants.ts +21 -0
- package/src/components/Axis/index.ts +7 -0
- package/src/components/BarChart/components/BarChart.tsx +7 -1
- package/src/components/Brush/BrushSelector.tsx +154 -22
- package/src/components/Brush/MiniChartPreview.tsx +138 -21
- package/src/components/EditorPanel/EditorPanel.tsx +25 -11
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +60 -22
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +81 -1
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -1
- package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +0 -1
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +21 -1
- package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
- package/src/components/HorizonChart/HorizonChart.tsx +131 -0
- package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
- package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
- package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
- package/src/components/HorizonChart/index.tsx +3 -0
- package/src/components/Legend/Legend.Component.tsx +52 -4
- package/src/components/Legend/Legend.tsx +1 -1
- package/src/components/Legend/LegendValueRange.tsx +77 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +13 -0
- package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
- package/src/components/LineChart/helpers/README.md +292 -0
- package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
- package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
- package/src/components/LineChart/index.tsx +44 -8
- package/src/components/LinearChart/README.md +109 -0
- package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
- package/src/components/LinearChart/linearChart.constants.ts +84 -0
- package/src/components/LinearChart/tests/LinearChart.test.tsx +201 -0
- package/src/components/LinearChart/tests/mockConfigContext.ts +129 -0
- package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
- package/src/components/LinearChart.tsx +250 -1059
- package/src/components/PieChart/PieChart.tsx +1 -1
- package/src/components/RadarChart/RadarAxis.tsx +78 -0
- package/src/components/RadarChart/RadarChart.tsx +298 -0
- package/src/components/RadarChart/RadarGrid.tsx +64 -0
- package/src/components/RadarChart/RadarPolygon.tsx +91 -0
- package/src/components/RadarChart/helpers.ts +83 -0
- package/src/components/RadarChart/index.tsx +3 -0
- package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
- package/src/data/initial-state.js +14 -1
- package/src/helpers/getExcludedData.ts +4 -0
- package/src/helpers/handleChartAriaLabels.ts +19 -19
- package/src/helpers/handleLineType.ts +22 -18
- package/src/hooks/useProgrammaticTooltip.ts +23 -2
- package/src/hooks/useScales.ts +7 -0
- package/src/hooks/useTooltip.tsx +3 -0
- package/src/scss/main.scss +5 -0
- package/src/selectors/README.md +68 -0
- package/src/store/chart.reducer.ts +2 -0
- package/src/types/ChartConfig.ts +18 -0
- package/src/types/ChartContext.ts +1 -0
- package/src/types/Horizon.ts +64 -0
- package/preview.html +0 -1616
- package/src/components/Annotations/components/helpers/index.tsx +0 -46
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useContext, memo, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
// cdc
|
|
4
|
+
import ConfigContext from '../../ConfigContext'
|
|
5
|
+
import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
|
|
6
|
+
import { isDateScale } from '@cdc/core/helpers/cove/date'
|
|
7
|
+
|
|
8
|
+
// visx
|
|
9
|
+
import { Bar } from '@visx/shape'
|
|
10
|
+
import { Group } from '@visx/group'
|
|
11
|
+
|
|
12
|
+
// components
|
|
13
|
+
import HorizonBand from './components/HorizonBand'
|
|
14
|
+
|
|
15
|
+
// helpers
|
|
16
|
+
import { calculateHorizonBands } from './helpers/calculateHorizonBands'
|
|
17
|
+
|
|
18
|
+
// types
|
|
19
|
+
import { HORIZON_DEFAULTS } from '../../types/Horizon'
|
|
20
|
+
|
|
21
|
+
type HorizonChartProps = {
|
|
22
|
+
xScale: any
|
|
23
|
+
yScale: any
|
|
24
|
+
xMax: number
|
|
25
|
+
yMax: number
|
|
26
|
+
handleTooltipMouseOver: (e: any, additionalData?: any) => void
|
|
27
|
+
handleTooltipMouseOff: () => void
|
|
28
|
+
tooltipData?: any
|
|
29
|
+
showTooltip?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HorizonChart = ({ xScale, xMax, yMax, handleTooltipMouseOver, handleTooltipMouseOff }: HorizonChartProps) => {
|
|
33
|
+
// Get data and config from context
|
|
34
|
+
const { transformedData: data, config, colorScale, rawData, parseDate } = useContext(ConfigContext)
|
|
35
|
+
|
|
36
|
+
const horizonConfig = {
|
|
37
|
+
...HORIZON_DEFAULTS,
|
|
38
|
+
...config.horizon
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Get series keys for rendering rows
|
|
42
|
+
const seriesKeys =
|
|
43
|
+
(config.runtime?.seriesKeys?.length ? config.runtime.seriesKeys : config.series?.map(s => s.dataKey)) || []
|
|
44
|
+
|
|
45
|
+
// Calculate value range across all horizon series (for consistent scaling)
|
|
46
|
+
// Must be called before early returns to satisfy React hooks rules
|
|
47
|
+
const valueRange = useMemo(() => {
|
|
48
|
+
if (!data || data.length === 0 || seriesKeys.length === 0) {
|
|
49
|
+
return { min: 0, max: 0 }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let min = Infinity
|
|
53
|
+
let max = -Infinity
|
|
54
|
+
|
|
55
|
+
data.forEach((row: any) => {
|
|
56
|
+
seriesKeys.forEach((key: string) => {
|
|
57
|
+
const value = Math.abs(Number(row[key]) || 0)
|
|
58
|
+
if (value > 0) {
|
|
59
|
+
min = Math.min(min, value)
|
|
60
|
+
max = Math.max(max, value)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
min: min === Infinity ? 0 : min,
|
|
67
|
+
max: max === -Infinity ? 0 : max
|
|
68
|
+
}
|
|
69
|
+
}, [data, seriesKeys])
|
|
70
|
+
|
|
71
|
+
// Early returns after all hooks
|
|
72
|
+
if (!data || data.length === 0) return null
|
|
73
|
+
if (seriesKeys.length === 0) return null
|
|
74
|
+
if (xMax <= 0 || yMax <= 0) return null
|
|
75
|
+
|
|
76
|
+
// Calculate band dimensions to fill available space
|
|
77
|
+
const { bandHeight, getRowY } = calculateHorizonBands(
|
|
78
|
+
seriesKeys.length,
|
|
79
|
+
yMax,
|
|
80
|
+
horizonConfig.bandGap,
|
|
81
|
+
horizonConfig.bottomPadding
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const getXPosition = value => {
|
|
85
|
+
if (config.xAxis.type === 'categorical') {
|
|
86
|
+
return xScale(value) + (xScale.bandwidth ? xScale.bandwidth() / 2 : 0)
|
|
87
|
+
}
|
|
88
|
+
if (isDateScale(config.xAxis)) {
|
|
89
|
+
const scaledValue = xScale(parseDate(value, false))
|
|
90
|
+
return scaledValue + (xScale.bandwidth ? xScale.bandwidth() / 2 : 0)
|
|
91
|
+
}
|
|
92
|
+
return xScale(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ErrorBoundary component='HorizonChart'>
|
|
97
|
+
<Group className='horizon-chart' key='horizon-wrapper' left={Number(config.yAxis.size)} height={Number(yMax)}>
|
|
98
|
+
{seriesKeys.map((seriesKey, index) => {
|
|
99
|
+
const rowY = getRowY(index)
|
|
100
|
+
return (
|
|
101
|
+
<Group key={seriesKey} top={rowY} className='horizon-band-row'>
|
|
102
|
+
{/* Horizon band for this series */}
|
|
103
|
+
<HorizonBand
|
|
104
|
+
data={data}
|
|
105
|
+
seriesKey={seriesKey}
|
|
106
|
+
xAxisKey={config.xAxis.dataKey}
|
|
107
|
+
getXPosition={getXPosition}
|
|
108
|
+
bandHeight={bandHeight}
|
|
109
|
+
xMax={xMax}
|
|
110
|
+
numLayers={horizonConfig.numLayers}
|
|
111
|
+
colorScale={colorScale}
|
|
112
|
+
config={config}
|
|
113
|
+
globalMax={valueRange.max}
|
|
114
|
+
/>
|
|
115
|
+
</Group>
|
|
116
|
+
)
|
|
117
|
+
})}
|
|
118
|
+
{/* Transparent bar for tooltip interaction */}
|
|
119
|
+
<Bar
|
|
120
|
+
width={Number(xMax)}
|
|
121
|
+
height={Number(yMax)}
|
|
122
|
+
fill='transparent'
|
|
123
|
+
onMouseMove={e => handleTooltipMouseOver(e, rawData)}
|
|
124
|
+
onMouseLeave={handleTooltipMouseOff}
|
|
125
|
+
/>
|
|
126
|
+
</Group>
|
|
127
|
+
</ErrorBoundary>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default memo(HorizonChart)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React, { memo, useId, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
// visx
|
|
4
|
+
import { AreaClosed } from '@visx/shape'
|
|
5
|
+
import { Group } from '@visx/group'
|
|
6
|
+
import { scaleLinear } from '@visx/scale'
|
|
7
|
+
import * as allCurves from '@visx/curve'
|
|
8
|
+
import { approvedCurveTypes } from '@cdc/core/helpers/lineChartHelpers'
|
|
9
|
+
import { getHorizonLayerColors } from '../helpers/getHorizonLayerColors'
|
|
10
|
+
|
|
11
|
+
type HorizonBandProps = {
|
|
12
|
+
data: any[]
|
|
13
|
+
seriesKey: string
|
|
14
|
+
xAxisKey: string
|
|
15
|
+
getXPosition: (value: any) => number
|
|
16
|
+
bandHeight: number
|
|
17
|
+
xMax: number
|
|
18
|
+
numLayers: number
|
|
19
|
+
colorScale: any
|
|
20
|
+
config: any
|
|
21
|
+
globalMax: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HorizonBand renders a single series as a horizon chart
|
|
26
|
+
*
|
|
27
|
+
* Horizon charts work by:
|
|
28
|
+
* 1. Dividing the value range into N layers
|
|
29
|
+
* 2. Each layer shows values within its threshold range
|
|
30
|
+
* 3. Layers are stacked/overlapped to create the horizon effect
|
|
31
|
+
* 4. Higher values appear overlapped, usually darker depending on color palette (achieved through layer stacking)
|
|
32
|
+
*/
|
|
33
|
+
const HorizonBand = ({
|
|
34
|
+
data,
|
|
35
|
+
seriesKey,
|
|
36
|
+
xAxisKey,
|
|
37
|
+
getXPosition,
|
|
38
|
+
bandHeight,
|
|
39
|
+
xMax,
|
|
40
|
+
numLayers,
|
|
41
|
+
config,
|
|
42
|
+
globalMax
|
|
43
|
+
}: HorizonBandProps) => {
|
|
44
|
+
// Create a unique, safe ID for clipPath (useId ensures uniqueness across instances)
|
|
45
|
+
// Must be called before any early returns to follow React's rules of hooks
|
|
46
|
+
const uniqueId = useId()
|
|
47
|
+
const safeSeriesKey = seriesKey.replace(/[^a-zA-Z0-9]/g, '-')
|
|
48
|
+
const clipId = `horizon-clip-${safeSeriesKey}-${uniqueId.replace(/:/g, '')}`
|
|
49
|
+
|
|
50
|
+
// Get the curve type from config (same as stacked area chart)
|
|
51
|
+
const curveType = allCurves[approvedCurveTypes[config.stackedAreaChartLineType || 'Linear']] || allCurves.curveLinear
|
|
52
|
+
|
|
53
|
+
// Process data: convert to absolute values and compute series max in single pass
|
|
54
|
+
const { processedData, seriesMax } = useMemo(() => {
|
|
55
|
+
let max = 0
|
|
56
|
+
const processed = data.map(d => {
|
|
57
|
+
const absValue = Math.abs(Number(d[seriesKey]) || 0)
|
|
58
|
+
if (absValue > max) max = absValue
|
|
59
|
+
return { ...d, [seriesKey]: absValue }
|
|
60
|
+
})
|
|
61
|
+
return { processedData: processed, seriesMax: max }
|
|
62
|
+
}, [data, seriesKey])
|
|
63
|
+
|
|
64
|
+
// Get layer colors using shared helper (memoized based on palette config and numLayers)
|
|
65
|
+
// Must be called before early returns to follow React's rules of hooks
|
|
66
|
+
const layerColors = useMemo(
|
|
67
|
+
() => getHorizonLayerColors(config, numLayers),
|
|
68
|
+
[
|
|
69
|
+
config.general?.palette?.name,
|
|
70
|
+
config.general?.palette?.isReversed,
|
|
71
|
+
config.general?.palette?.version,
|
|
72
|
+
config.general?.palette?.customColors,
|
|
73
|
+
numLayers
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Use global max for scaling (ensures all series bands are comparable)
|
|
78
|
+
const maxValue = globalMax
|
|
79
|
+
|
|
80
|
+
// If no data, max is 0, or dimensions are invalid, don't render
|
|
81
|
+
if (maxValue === 0) return null
|
|
82
|
+
if (xMax <= 0 || bandHeight <= 0) return null
|
|
83
|
+
|
|
84
|
+
// Calculate the threshold for each layer
|
|
85
|
+
// Each layer represents 1/numLayers of the max value
|
|
86
|
+
const layerThreshold = maxValue / numLayers
|
|
87
|
+
|
|
88
|
+
// Create a y-scale for positioning within the band
|
|
89
|
+
// The scale maps values 0-layerThreshold to the full bandHeight
|
|
90
|
+
// Each layer uses the full band height, creating overlay effect
|
|
91
|
+
const yScale = scaleLinear({
|
|
92
|
+
domain: [0, layerThreshold],
|
|
93
|
+
range: [bandHeight, 0],
|
|
94
|
+
clamp: true // Clamp values above threshold
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Render layers from bottom to top
|
|
98
|
+
// Each layer shows values from (layerIndex * threshold) to ((layerIndex + 1) * threshold)
|
|
99
|
+
const layers = []
|
|
100
|
+
|
|
101
|
+
for (let layerIndex = 0; layerIndex < numLayers; layerIndex++) {
|
|
102
|
+
const layerMin = layerIndex * layerThreshold
|
|
103
|
+
|
|
104
|
+
// Short-circuit: if this layer's minimum exceeds the series max,
|
|
105
|
+
// no remaining layers can have visible data
|
|
106
|
+
if (layerMin >= seriesMax) break
|
|
107
|
+
|
|
108
|
+
// Build layer data and track hasData in a single pass
|
|
109
|
+
let hasData = false
|
|
110
|
+
const layerData = processedData.map(d => {
|
|
111
|
+
const rawValue = d[seriesKey]
|
|
112
|
+
// Calculate the value relative to this layer's base
|
|
113
|
+
const layerValue = Math.max(0, rawValue - layerMin)
|
|
114
|
+
// Clamp to the layer threshold
|
|
115
|
+
const clampedValue = Math.min(layerValue, layerThreshold)
|
|
116
|
+
|
|
117
|
+
if (clampedValue > 0) hasData = true
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
x: d[xAxisKey],
|
|
121
|
+
y: clampedValue
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (!hasData) continue
|
|
126
|
+
|
|
127
|
+
// Get color for this layer from the distributed layer colors
|
|
128
|
+
const layerColor = layerColors[layerIndex]
|
|
129
|
+
|
|
130
|
+
layers.push(
|
|
131
|
+
<Group key={`layer-${layerIndex}`} top={0}>
|
|
132
|
+
<AreaClosed
|
|
133
|
+
data={layerData}
|
|
134
|
+
x={d => getXPosition(d.x)}
|
|
135
|
+
y={d => yScale(d.y)}
|
|
136
|
+
yScale={yScale}
|
|
137
|
+
curve={curveType}
|
|
138
|
+
fill={layerColor}
|
|
139
|
+
fillOpacity={1}
|
|
140
|
+
stroke='none'
|
|
141
|
+
/>
|
|
142
|
+
</Group>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Group className='horizon-band'>
|
|
148
|
+
{/* Clip to band bounds */}
|
|
149
|
+
<defs>
|
|
150
|
+
<clipPath id={clipId}>
|
|
151
|
+
<rect x={0} y={0} width={xMax} height={bandHeight} />
|
|
152
|
+
</clipPath>
|
|
153
|
+
</defs>
|
|
154
|
+
|
|
155
|
+
<Group clipPath={`url(#${clipId})`}>{layers}</Group>
|
|
156
|
+
</Group>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default memo(HorizonBand)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates the band dimensions for horizon chart rows
|
|
3
|
+
* Used by both HorizonChart (for rendering) and LeftAxis (for label positioning)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MIN_BAND_HEIGHT = 10
|
|
7
|
+
|
|
8
|
+
export type HorizonBandCalculation = {
|
|
9
|
+
bandHeight: number
|
|
10
|
+
getRowY: (index: number) => number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function calculateHorizonBands(
|
|
14
|
+
numSeries: number,
|
|
15
|
+
yMax: number,
|
|
16
|
+
bandGap: number | string,
|
|
17
|
+
bottomPadding: number | string = 15
|
|
18
|
+
): HorizonBandCalculation {
|
|
19
|
+
const gap = Number(bandGap) || 0
|
|
20
|
+
const padding = Number(bottomPadding) || 0
|
|
21
|
+
|
|
22
|
+
const totalGapSpace = (numSeries - 1) * gap + padding
|
|
23
|
+
const bandHeight = Math.max((yMax - totalGapSpace) / numSeries, MIN_BAND_HEIGHT)
|
|
24
|
+
const getRowY = (index: number) => index * (bandHeight + gap)
|
|
25
|
+
|
|
26
|
+
return { bandHeight, getRowY }
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
|
|
2
|
+
import { v2ColorDistribution } from '@cdc/core/helpers/palettes/colorDistributions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculates the layer colors for a horizon chart based on palette configuration.
|
|
6
|
+
* Shared between HorizonBand rendering and Legend display.
|
|
7
|
+
*/
|
|
8
|
+
export const getHorizonLayerColors = (config: any, numLayers: number): string[] => {
|
|
9
|
+
const paletteName = config.general?.palette?.name || 'sequential_blue'
|
|
10
|
+
const colorPalettes = filterChartColorPalettes(config)
|
|
11
|
+
const fullPalette = colorPalettes[paletteName] || Object.values(colorPalettes)[0] || ['#4292c6']
|
|
12
|
+
|
|
13
|
+
// Use v2ColorDistribution if we have a 9-color palette and numLayers <= 9
|
|
14
|
+
if (fullPalette.length === 9 && numLayers <= 9 && v2ColorDistribution[numLayers]) {
|
|
15
|
+
const indices = v2ColorDistribution[numLayers]
|
|
16
|
+
return indices.map((i: number) => fullPalette[i])
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Fallback: take first numLayers colors, or repeat if needed
|
|
20
|
+
return Array.from({ length: numLayers }, (_, i) => fullPalette[i % fullPalette.length])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Calculates the max value across all series in a horizon chart dataset.
|
|
25
|
+
* Used for consistent scaling across all bands.
|
|
26
|
+
*/
|
|
27
|
+
export const getHorizonMaxValue = (data: any[], seriesKeys: string[]): number => {
|
|
28
|
+
if (!data || data.length === 0 || !seriesKeys || seriesKeys.length === 0) {
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let max = 0
|
|
33
|
+
for (const row of data) {
|
|
34
|
+
for (const key of seriesKeys) {
|
|
35
|
+
const value = Math.abs(Number(row[key]) || 0)
|
|
36
|
+
if (value > max) max = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return max
|
|
40
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import parse from 'html-react-parser'
|
|
2
|
-
import React from 'react'
|
|
2
|
+
import React, { useMemo } from 'react'
|
|
3
3
|
import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend'
|
|
4
4
|
import { PatternLines, PatternCircles, PatternWaves } from '@visx/pattern'
|
|
5
5
|
import LegendShape from '@cdc/core/components/LegendShape'
|
|
@@ -17,6 +17,8 @@ import { DimensionsType } from '@cdc/core/types/Dimensions'
|
|
|
17
17
|
import { isLegendWrapViewport } from '@cdc/core/helpers/viewports'
|
|
18
18
|
import LegendLineShape from './LegendLine.Shape'
|
|
19
19
|
import LegendGroup from './LegendGroup'
|
|
20
|
+
import LegendValueRange from './LegendValueRange'
|
|
21
|
+
import { getHorizonLayerColors, getHorizonMaxValue } from '../../components/HorizonChart/helpers/getHorizonLayerColors'
|
|
20
22
|
import { getSeriesWithData } from '../../helpers/dataHelpers'
|
|
21
23
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
22
24
|
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
@@ -28,6 +30,7 @@ interface LegendProps {
|
|
|
28
30
|
config: ChartConfig
|
|
29
31
|
currentViewport: ViewportSize
|
|
30
32
|
formatLabels: (labels: Label[]) => Label[]
|
|
33
|
+
formatNumber?: (value: number, axis?: string) => string
|
|
31
34
|
highlight: Function
|
|
32
35
|
handleShowAll: Function
|
|
33
36
|
ref: React.Ref<() => void>
|
|
@@ -48,6 +51,7 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
48
51
|
handleShowAll,
|
|
49
52
|
currentViewport,
|
|
50
53
|
formatLabels,
|
|
54
|
+
formatNumber,
|
|
51
55
|
skipId = 'legend',
|
|
52
56
|
dimensions,
|
|
53
57
|
transformedData: data,
|
|
@@ -60,7 +64,10 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
60
64
|
const { series } = runtime
|
|
61
65
|
|
|
62
66
|
const seriesWithData = getSeriesWithData(config)
|
|
63
|
-
|
|
67
|
+
// For Radar charts, seriesWithData contains dimension keys but legend shows entity names
|
|
68
|
+
// so we skip the series filter for radar charts
|
|
69
|
+
const isRadarChart = config.visualizationType === 'Radar'
|
|
70
|
+
const dontFilterLegendItems = !series.length || legend.unified || !seriesWithData.length || isRadarChart
|
|
64
71
|
|
|
65
72
|
const isLegendBottom =
|
|
66
73
|
legend?.position === 'bottom' ||
|
|
@@ -74,6 +81,29 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
74
81
|
const { HighLightedBarUtils } = useHighlightedBars(config)
|
|
75
82
|
let highLightedLegendItems = HighLightedBarUtils.findDuplicates(config.highlightedBarValues)
|
|
76
83
|
|
|
84
|
+
const horizonLegendData = useMemo(() => {
|
|
85
|
+
if (config.visualizationType !== 'Horizon Chart') {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
const numLayers = config.horizon?.numLayers || 4
|
|
89
|
+
const runtimeSeriesKeys = config.runtime?.seriesKeys
|
|
90
|
+
const seriesKeys =
|
|
91
|
+
(Array.isArray(runtimeSeriesKeys) && runtimeSeriesKeys.length > 0
|
|
92
|
+
? runtimeSeriesKeys
|
|
93
|
+
: config.series?.map(s => s.dataKey)) || []
|
|
94
|
+
const maxValue = getHorizonMaxValue(data, seriesKeys)
|
|
95
|
+
const layerColors = getHorizonLayerColors(config, numLayers)
|
|
96
|
+
|
|
97
|
+
return { numLayers, maxValue, layerColors }
|
|
98
|
+
}, [
|
|
99
|
+
config.visualizationType,
|
|
100
|
+
config.horizon?.numLayers,
|
|
101
|
+
config.runtime?.seriesKeys,
|
|
102
|
+
config.series,
|
|
103
|
+
config.general?.palette?.name,
|
|
104
|
+
data
|
|
105
|
+
])
|
|
106
|
+
|
|
77
107
|
if (!legend) return null
|
|
78
108
|
return (
|
|
79
109
|
<aside
|
|
@@ -99,6 +129,20 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
99
129
|
/>
|
|
100
130
|
<LegendGroup formatLabels={formatLabels} />
|
|
101
131
|
|
|
132
|
+
{/* Value Range Legend for Horizon Chart (and future chart types) */}
|
|
133
|
+
{horizonLegendData && (
|
|
134
|
+
<LegendValueRange
|
|
135
|
+
maxValue={horizonLegendData.maxValue}
|
|
136
|
+
numRanges={horizonLegendData.numLayers}
|
|
137
|
+
colors={horizonLegendData.layerColors}
|
|
138
|
+
formatNumber={formatNumber}
|
|
139
|
+
innerClasses={innerClasses}
|
|
140
|
+
shape={config.legend.style === 'boxes' ? 'square' : 'circle'}
|
|
141
|
+
onClick={undefined}
|
|
142
|
+
reverseLabelOrder={config.legend.reverseLabelOrder}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
|
|
102
146
|
<LegendOrdinal scale={colorScale} itemDirection='row' labelMargin='0 20px 0 0' shapeMargin='0 10px 0'>
|
|
103
147
|
{labels => {
|
|
104
148
|
return (
|
|
@@ -130,8 +174,12 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
130
174
|
} else className.push('highlighted')
|
|
131
175
|
}
|
|
132
176
|
|
|
133
|
-
if (
|
|
134
|
-
|
|
177
|
+
if (
|
|
178
|
+
config.legend.style === 'gradient' ||
|
|
179
|
+
config.legend.groupBy ||
|
|
180
|
+
config.visualizationType === 'Horizon Chart'
|
|
181
|
+
) {
|
|
182
|
+
return null
|
|
135
183
|
}
|
|
136
184
|
|
|
137
185
|
return (
|
|
@@ -3,7 +3,6 @@ import ConfigContext from '../../ConfigContext'
|
|
|
3
3
|
import LegendComponent from './Legend.Component'
|
|
4
4
|
import { createFormatLabels } from './helpers/createFormatLabels'
|
|
5
5
|
|
|
6
|
-
/* eslint-disable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */
|
|
7
6
|
const Legend = forwardRef((props, ref) => {
|
|
8
7
|
// prettier-ignore
|
|
9
8
|
const {
|
|
@@ -42,6 +41,7 @@ const Legend = forwardRef((props, ref) => {
|
|
|
42
41
|
handleShowAll={handleShowAll}
|
|
43
42
|
currentViewport={currentViewport}
|
|
44
43
|
formatLabels={createLegendLabels}
|
|
44
|
+
formatNumber={formatNumber}
|
|
45
45
|
interactionLabel={interactionLabel}
|
|
46
46
|
/>
|
|
47
47
|
</Fragment>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { LegendItem, LegendLabel } from '@visx/legend'
|
|
3
|
+
import LegendShape from '@cdc/core/components/LegendShape'
|
|
4
|
+
import { generateValueRanges, GenerateValueRangesOptions, ValueRange } from './helpers/generateValueRanges'
|
|
5
|
+
|
|
6
|
+
export type LegendValueRangeProps = GenerateValueRangesOptions & {
|
|
7
|
+
colors: string[]
|
|
8
|
+
shape?: 'circle' | 'square'
|
|
9
|
+
onClick?: (index: number, range: ValueRange) => void
|
|
10
|
+
innerClasses?: string[]
|
|
11
|
+
reverseLabelOrder?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* LegendValueRange - Generic component for displaying value range legends
|
|
16
|
+
*
|
|
17
|
+
* Used by Horizon Charts to show layer intensity ranges (e.g., 1-100, 101-200).
|
|
18
|
+
* Can be reused by other chart types that need binned value legends.
|
|
19
|
+
*/
|
|
20
|
+
const LegendValueRange: React.FC<LegendValueRangeProps> = ({
|
|
21
|
+
minValue = 0,
|
|
22
|
+
maxValue,
|
|
23
|
+
numRanges,
|
|
24
|
+
distribution = 'equal',
|
|
25
|
+
formatNumber,
|
|
26
|
+
colors,
|
|
27
|
+
shape = 'square',
|
|
28
|
+
onClick,
|
|
29
|
+
innerClasses = ['legend-container__inner'],
|
|
30
|
+
reverseLabelOrder = false
|
|
31
|
+
}) => {
|
|
32
|
+
const ranges = generateValueRanges({ minValue, maxValue, numRanges, distribution, formatNumber })
|
|
33
|
+
|
|
34
|
+
if (ranges.length === 0) return null
|
|
35
|
+
|
|
36
|
+
// Reverse both ranges and colors when reverseLabelOrder is true
|
|
37
|
+
const displayRanges = reverseLabelOrder ? [...ranges].reverse() : ranges
|
|
38
|
+
const displayColors = reverseLabelOrder ? [...colors].reverse() : colors
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={innerClasses.join(' ')}>
|
|
42
|
+
{displayRanges.map((range, i) => {
|
|
43
|
+
const color = displayColors[i % displayColors.length]
|
|
44
|
+
const isClickable = typeof onClick === 'function'
|
|
45
|
+
const className = ['legend-item', `legend-text--range-${i}`, !isClickable && 'not-clickable'].filter(Boolean)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<LegendItem
|
|
49
|
+
className={className.join(' ')}
|
|
50
|
+
tabIndex={isClickable ? 0 : -1}
|
|
51
|
+
key={`legend-range-${i}`}
|
|
52
|
+
onKeyDown={e => {
|
|
53
|
+
if (isClickable && e.key === 'Enter') {
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
onClick(i, range)
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
onClick={e => {
|
|
59
|
+
if (isClickable) {
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
onClick(i, range)
|
|
62
|
+
}
|
|
63
|
+
}}
|
|
64
|
+
role={isClickable ? 'button' : undefined}
|
|
65
|
+
>
|
|
66
|
+
<LegendShape shape={shape} fill={color} />
|
|
67
|
+
<LegendLabel align='left' className='m-0'>
|
|
68
|
+
{range.label}
|
|
69
|
+
</LegendLabel>
|
|
70
|
+
</LegendItem>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default LegendValueRange
|
|
@@ -342,6 +342,19 @@ export const createFormatLabels =
|
|
|
342
342
|
return reverseLabels(seriesLabels)
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// For Radar charts, use the entity names from runtime.seriesKeys (set from xAxis.dataKey values)
|
|
346
|
+
// not the series names (which are the dimension keys)
|
|
347
|
+
if (visualizationType === 'Radar') {
|
|
348
|
+
const entityNames = runtime.seriesKeys || []
|
|
349
|
+
const radarLabels = entityNames.map((val, i) => ({
|
|
350
|
+
datum: val,
|
|
351
|
+
index: i,
|
|
352
|
+
text: String(val),
|
|
353
|
+
value: colorScale(val)
|
|
354
|
+
}))
|
|
355
|
+
return reverseLabels(radarLabels)
|
|
356
|
+
}
|
|
357
|
+
|
|
345
358
|
if (config.series.some(item => item.name)) {
|
|
346
359
|
const uniqueLabels = Array.from(new Set(config.series.map(d => d.name || d.dataKey))).map((val, i) => ({
|
|
347
360
|
datum: val,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateValueRanges - Creates value range bins for legend display
|
|
3
|
+
*
|
|
4
|
+
* Supports equal interval distribution with scaffolding for future quantile support.
|
|
5
|
+
* Ranges are inclusive with no overlap, starting at minValue or 0 (e.g., 1-100, 101-200).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type RangeDistribution = 'equal' | 'quantile'
|
|
9
|
+
|
|
10
|
+
export type ValueRange = {
|
|
11
|
+
min: number
|
|
12
|
+
max: number
|
|
13
|
+
label: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type GenerateValueRangesOptions = {
|
|
17
|
+
minValue?: number
|
|
18
|
+
maxValue: number
|
|
19
|
+
numRanges: number
|
|
20
|
+
distribution?: RangeDistribution
|
|
21
|
+
formatNumber?: (value: number, axis?: string) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generates an array of value ranges for legend display
|
|
26
|
+
*/
|
|
27
|
+
export const generateValueRanges = ({
|
|
28
|
+
minValue = 0,
|
|
29
|
+
maxValue,
|
|
30
|
+
numRanges,
|
|
31
|
+
distribution = 'equal',
|
|
32
|
+
formatNumber
|
|
33
|
+
}: GenerateValueRangesOptions): ValueRange[] => {
|
|
34
|
+
if (numRanges <= 0 || maxValue <= minValue) {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ranges: ValueRange[] = []
|
|
39
|
+
|
|
40
|
+
if (distribution === 'equal') {
|
|
41
|
+
const rangeSize = (maxValue - minValue) / numRanges
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < numRanges; i++) {
|
|
44
|
+
// Calculate raw boundaries
|
|
45
|
+
const rawMin = minValue + i * rangeSize
|
|
46
|
+
const rawMax = minValue + (i + 1) * rangeSize
|
|
47
|
+
|
|
48
|
+
// For display:
|
|
49
|
+
// - First range starts at floor of minValue
|
|
50
|
+
// - Subsequent ranges start at previous max + 1 (monotonic constraint)
|
|
51
|
+
// - Last range ends exactly at ceil of maxValue
|
|
52
|
+
let displayMin: number
|
|
53
|
+
let displayMax: number
|
|
54
|
+
|
|
55
|
+
if (i === 0) {
|
|
56
|
+
displayMin = Math.floor(rawMin)
|
|
57
|
+
} else {
|
|
58
|
+
// Start at previous range's max + 1 to avoid overlap
|
|
59
|
+
displayMin = ranges[i - 1].max + 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (i === numRanges - 1) {
|
|
63
|
+
// Last range ends exactly at maxValue
|
|
64
|
+
displayMax = Math.ceil(maxValue)
|
|
65
|
+
} else {
|
|
66
|
+
displayMax = Math.floor(rawMax)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ensure min <= max (can happen with very small ranges)
|
|
70
|
+
if (displayMin > displayMax) {
|
|
71
|
+
displayMax = displayMin
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Format numbers if formatter provided
|
|
75
|
+
const formattedMin = formatNumber ? formatNumber(displayMin, 'left') : displayMin.toLocaleString()
|
|
76
|
+
const formattedMax = formatNumber ? formatNumber(displayMax, 'left') : displayMax.toLocaleString()
|
|
77
|
+
|
|
78
|
+
ranges.push({
|
|
79
|
+
min: displayMin,
|
|
80
|
+
max: displayMax,
|
|
81
|
+
label: displayMin === displayMax ? formattedMin : `${formattedMin}–${formattedMax}`
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
} else if (distribution === 'quantile') {
|
|
85
|
+
// Scaffolding for future quantile-based distribution
|
|
86
|
+
// Would require passing in the actual data values to calculate percentiles
|
|
87
|
+
// Currently, silently fall back to equal interval distribution
|
|
88
|
+
return generateValueRanges({ minValue, maxValue, numRanges, distribution: 'equal', formatNumber })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ranges
|
|
92
|
+
}
|