@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.
Files changed (132) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  3. package/dist/cdcchart.js +45357 -43655
  4. package/examples/default.json +378 -0
  5. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  6. package/examples/feature/annotations/index.json +3 -6
  7. package/examples/feature/horizon/horizon-chart.json +395 -0
  8. package/examples/line-chart-states.json +1085 -0
  9. package/examples/private/123.json +694 -0
  10. package/examples/private/anchor-issue.json +4094 -0
  11. package/examples/private/backwards-slider.json +10430 -0
  12. package/examples/private/georgia.csv +160 -0
  13. package/examples/private/timeline-data.json +1 -0
  14. package/examples/private/timeline.json +389 -0
  15. package/examples/radar-chart-simple.json +133 -0
  16. package/examples/radar-chart.json +148 -0
  17. package/index.html +1 -31
  18. package/package.json +57 -59
  19. package/src/CdcChartComponent.tsx +99 -18
  20. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  21. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  22. package/src/_stories/Chart.CI.stories.tsx +13 -0
  23. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  24. package/src/_stories/Chart.CustomColors.stories.tsx +4 -0
  25. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  26. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  27. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  28. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  29. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  30. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  31. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  32. package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
  33. package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
  34. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
  35. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  36. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  37. package/src/_stories/Chart.stories.tsx +37 -0
  38. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  39. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  40. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  41. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  42. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  43. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  44. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  45. package/src/_stories/ChartBrush.stories.tsx +7 -0
  46. package/src/_stories/ChartEditor.stories.tsx +7 -0
  47. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  48. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  49. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  50. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  51. package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
  52. package/src/_stories/_mock/brush_continuous.json +86 -0
  53. package/src/_stories/_mock/brush_date_large.json +176 -0
  54. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  55. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  56. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  57. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  58. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  59. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  60. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  61. package/src/components/Annotations/components/AnnotationDraggable.styles.css +11 -17
  62. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  63. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  64. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  65. package/src/components/Annotations/components/AnnotationList.styles.css +4 -10
  66. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  67. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  68. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  69. package/src/components/Axis/BottomAxis.tsx +270 -0
  70. package/src/components/Axis/LeftAxis.tsx +404 -0
  71. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  72. package/src/components/Axis/PairedBarAxis.tsx +186 -0
  73. package/src/components/Axis/README.md +94 -0
  74. package/src/components/Axis/RightAxis.tsx +108 -0
  75. package/src/components/Axis/axis.constants.ts +21 -0
  76. package/src/components/Axis/index.ts +7 -0
  77. package/src/components/BarChart/components/BarChart.tsx +7 -1
  78. package/src/components/Brush/BrushSelector.tsx +154 -22
  79. package/src/components/Brush/MiniChartPreview.tsx +138 -21
  80. package/src/components/EditorPanel/EditorPanel.tsx +25 -11
  81. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +60 -22
  82. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +81 -1
  83. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -1
  84. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  85. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +0 -1
  86. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +21 -1
  87. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  88. package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
  89. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  90. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  91. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  92. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  93. package/src/components/HorizonChart/index.tsx +3 -0
  94. package/src/components/Legend/Legend.Component.tsx +52 -4
  95. package/src/components/Legend/Legend.tsx +1 -1
  96. package/src/components/Legend/LegendValueRange.tsx +77 -0
  97. package/src/components/Legend/helpers/createFormatLabels.tsx +13 -0
  98. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  99. package/src/components/LineChart/helpers/README.md +292 -0
  100. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  101. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  102. package/src/components/LineChart/index.tsx +44 -8
  103. package/src/components/LinearChart/README.md +109 -0
  104. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  105. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  106. package/src/components/LinearChart/tests/LinearChart.test.tsx +201 -0
  107. package/src/components/LinearChart/tests/mockConfigContext.ts +129 -0
  108. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  109. package/src/components/LinearChart.tsx +250 -1059
  110. package/src/components/PieChart/PieChart.tsx +1 -1
  111. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  112. package/src/components/RadarChart/RadarChart.tsx +298 -0
  113. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  114. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  115. package/src/components/RadarChart/helpers.ts +83 -0
  116. package/src/components/RadarChart/index.tsx +3 -0
  117. package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
  118. package/src/data/initial-state.js +14 -1
  119. package/src/helpers/getExcludedData.ts +4 -0
  120. package/src/helpers/handleChartAriaLabels.ts +19 -19
  121. package/src/helpers/handleLineType.ts +22 -18
  122. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  123. package/src/hooks/useScales.ts +7 -0
  124. package/src/hooks/useTooltip.tsx +3 -0
  125. package/src/scss/main.scss +5 -0
  126. package/src/selectors/README.md +68 -0
  127. package/src/store/chart.reducer.ts +2 -0
  128. package/src/types/ChartConfig.ts +18 -0
  129. package/src/types/ChartContext.ts +1 -0
  130. package/src/types/Horizon.ts +64 -0
  131. package/preview.html +0 -1616
  132. 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
+ }
@@ -0,0 +1,3 @@
1
+ import HorizonChart from './HorizonChart'
2
+
3
+ export { HorizonChart }
@@ -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
- const dontFilterLegendItems = !series.length || legend.unified || !seriesWithData.length
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 (config.legend.style === 'gradient' || config.legend.groupBy) {
134
- return <></>
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
+ }