@cdc/chart 4.25.11 → 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 (181) 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 +51401 -50814
  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/feature/pie/planet-pie-example-config.json +48 -2
  9. package/examples/line-chart-states.json +1085 -0
  10. package/examples/private/123.json +694 -0
  11. package/examples/private/DEV-12100.json +1303 -0
  12. package/examples/private/anchor-issue.json +4094 -0
  13. package/examples/private/backwards-slider.json +10430 -0
  14. package/examples/private/cat-y.json +1235 -0
  15. package/examples/private/data-points.json +228 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/height.json +3915 -0
  18. package/examples/private/links.json +569 -0
  19. package/examples/private/quadrant.txt +30 -0
  20. package/examples/private/test-forecast.json +5510 -0
  21. package/examples/private/timeline-data.json +1 -0
  22. package/examples/private/timeline.json +389 -0
  23. package/examples/private/warming-stripe-test.json +2578 -0
  24. package/examples/private/warming-stripes.json +4763 -0
  25. package/examples/radar-chart-simple.json +133 -0
  26. package/examples/radar-chart.json +148 -0
  27. package/examples/tech-adoption-with-links.json +560 -0
  28. package/index.html +1 -36
  29. package/package.json +59 -60
  30. package/src/CdcChartComponent.tsx +206 -89
  31. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  32. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  33. package/src/_stories/Chart.CI.stories.tsx +13 -0
  34. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  35. package/src/_stories/Chart.CustomColors.stories.tsx +4 -0
  36. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  37. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  38. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  39. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  40. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  41. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  42. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  43. package/src/_stories/Chart.Regions.Categorical.stories.tsx +161 -0
  44. package/src/_stories/Chart.Regions.DateScale.stories.tsx +216 -0
  45. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +312 -0
  46. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  47. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  48. package/src/_stories/Chart.stories.tsx +45 -0
  49. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  50. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  51. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  52. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  53. package/src/_stories/ChartBar.Editor.stories.tsx +11 -6
  54. package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
  55. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  56. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  57. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  58. package/src/_stories/ChartBrush.stories.tsx +57 -0
  59. package/src/_stories/ChartEditor.Editor.stories.tsx +3 -5
  60. package/src/_stories/ChartEditor.stories.tsx +7 -0
  61. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  62. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  63. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  64. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  65. package/src/_stories/TechAdoptionWithLinks.stories.tsx +34 -0
  66. package/src/_stories/_mock/brush_continuous.json +86 -0
  67. package/src/_stories/_mock/brush_date_large.json +176 -0
  68. package/src/_stories/_mock/brush_enabled.json +326 -0
  69. package/src/_stories/_mock/brush_mock.json +2 -69
  70. package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
  71. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  72. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  73. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  74. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  75. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  76. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  77. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  78. package/src/components/Annotations/components/AnnotationDraggable.styles.css +11 -17
  79. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  80. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  81. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  82. package/src/components/Annotations/components/AnnotationList.styles.css +4 -10
  83. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  84. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  85. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  86. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +1 -2
  87. package/src/components/Axis/BottomAxis.tsx +270 -0
  88. package/src/components/Axis/Categorical.Axis.tsx +6 -7
  89. package/src/components/Axis/LeftAxis.tsx +404 -0
  90. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  91. package/src/components/Axis/PairedBarAxis.tsx +186 -0
  92. package/src/components/Axis/README.md +94 -0
  93. package/src/components/Axis/RightAxis.tsx +108 -0
  94. package/src/components/Axis/axis.constants.ts +21 -0
  95. package/src/components/Axis/index.ts +7 -0
  96. package/src/components/BarChart/components/BarChart.Horizontal.tsx +178 -24
  97. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
  98. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
  99. package/src/components/BarChart/components/BarChart.Vertical.tsx +6 -8
  100. package/src/components/BarChart/components/BarChart.tsx +7 -1
  101. package/src/components/BarChart/components/context.tsx +1 -0
  102. package/src/components/BarChart/helpers/useBarChart.ts +14 -2
  103. package/src/components/Brush/BrushSelector.tsx +1390 -0
  104. package/src/components/Brush/MiniChartPreview.tsx +400 -0
  105. package/src/components/DeviationBar.jsx +9 -7
  106. package/src/components/EditorPanel/EditorPanel.tsx +2734 -2595
  107. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +60 -22
  108. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
  109. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +137 -30
  110. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +2 -0
  111. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  112. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +0 -1
  113. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +30 -25
  114. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +42 -28
  115. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  116. package/src/components/EditorPanel/useEditorPermissions.ts +81 -39
  117. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  118. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  119. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  120. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  121. package/src/components/HorizonChart/index.tsx +3 -0
  122. package/src/components/Legend/Legend.Component.tsx +52 -4
  123. package/src/components/Legend/Legend.tsx +4 -3
  124. package/src/components/Legend/LegendValueRange.tsx +77 -0
  125. package/src/components/Legend/helpers/createFormatLabels.tsx +164 -2
  126. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  127. package/src/components/Legend/helpers/index.ts +10 -6
  128. package/src/components/LineChart/helpers/README.md +292 -0
  129. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  130. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  131. package/src/components/LineChart/index.tsx +44 -8
  132. package/src/components/LinearChart/README.md +109 -0
  133. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  134. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  135. package/src/components/LinearChart/tests/LinearChart.test.tsx +201 -0
  136. package/src/components/LinearChart/tests/mockConfigContext.ts +129 -0
  137. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  138. package/src/components/LinearChart.tsx +338 -1082
  139. package/src/components/PairedBarChart.jsx +20 -3
  140. package/src/components/PieChart/PieChart.tsx +1 -1
  141. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  142. package/src/components/RadarChart/RadarChart.tsx +298 -0
  143. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  144. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  145. package/src/components/RadarChart/helpers.ts +83 -0
  146. package/src/components/RadarChart/index.tsx +3 -0
  147. package/src/components/Regions/components/Regions.tsx +365 -122
  148. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
  149. package/src/components/SmallMultiples/SmallMultipleTile.tsx +5 -1
  150. package/src/components/WarmingStripes/WarmingStripes.tsx +230 -0
  151. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
  152. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
  153. package/src/components/WarmingStripes/index.tsx +3 -0
  154. package/src/data/initial-state.js +17 -2
  155. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
  156. package/src/helpers/getExcludedData.ts +4 -0
  157. package/src/helpers/getMinMax.ts +12 -7
  158. package/src/helpers/handleChartAriaLabels.ts +19 -19
  159. package/src/helpers/handleLineType.ts +22 -18
  160. package/src/helpers/sizeHelpers.ts +0 -20
  161. package/src/helpers/smallMultiplesHelpers.ts +1 -1
  162. package/src/hooks/useChartHoverAnalytics.tsx +10 -9
  163. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  164. package/src/hooks/useScales.ts +18 -1
  165. package/src/hooks/useTooltip.tsx +34 -10
  166. package/src/scss/DataTable.scss +0 -4
  167. package/src/scss/main.scss +22 -3
  168. package/src/selectors/README.md +68 -0
  169. package/src/store/chart.reducer.ts +2 -0
  170. package/src/test/CdcChart.test.jsx +1 -1
  171. package/src/types/ChartConfig.ts +21 -0
  172. package/src/types/ChartContext.ts +1 -0
  173. package/src/types/Horizon.ts +64 -0
  174. package/src/types/Label.ts +1 -0
  175. package/src/utils/analyticsTracking.ts +19 -0
  176. package/LICENSE +0 -201
  177. package/src/components/Annotations/components/helpers/index.tsx +0 -46
  178. package/src/components/Brush/BrushChart.tsx +0 -128
  179. package/src/components/Brush/BrushController.tsx +0 -71
  180. package/src/components/Brush/types.tsx +0 -8
  181. package/src/components/BrushChart.tsx +0 -223
@@ -8,12 +8,21 @@ import ConfigContext from '../ConfigContext'
8
8
  import { getContrastColor } from '@cdc/core/helpers/cove/accessibility'
9
9
  import { APP_FONT_COLOR } from '@cdc/core/helpers/constants'
10
10
  import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
11
+ import { isMobileFontViewport } from '@cdc/core/helpers/viewports'
11
12
 
12
13
  const PairedBarChart = ({ width, height, originalWidth }) => {
13
- const { config, colorScale, transformedData: data, formatNumber, seriesHighlight } = useContext(ConfigContext)
14
+ const {
15
+ config,
16
+ colorScale,
17
+ transformedData: data,
18
+ formatNumber,
19
+ seriesHighlight,
20
+ vizViewport
21
+ } = useContext(ConfigContext)
14
22
 
15
23
  if (!config || config?.series?.length < 2) return
16
24
 
25
+ const labelFontSize = isMobileFontViewport(vizViewport) ? 13 : 16
17
26
  const borderWidth = config.barHasBorder === 'true' ? 1 : 0
18
27
  const halfWidth = width / 2
19
28
  const offset = 1.02 // Offset of the left bar from the Axis
@@ -109,7 +118,10 @@ const PairedBarChart = ({ width, height, originalWidth }) => {
109
118
  const totalheight = (Number(config.barSpace) + barHeight + borderWidth) * data.length
110
119
  config.heights.horizontal = totalheight
111
120
  // check if text fits inside of the bar including suffix/prefix,comma,fontSize ..etc
112
- const textWidth = getTextWidth(formatNumber(d[groupOne.dataKey], 'left'))
121
+ const textWidth = getTextWidth(
122
+ formatNumber(d[groupOne.dataKey], 'left'),
123
+ `normal ${labelFontSize}px sans-serif`
124
+ )
113
125
  const textFits = textWidth < barWidth - 5 // minus padding dx(5)
114
126
 
115
127
  return (
@@ -141,6 +153,7 @@ const PairedBarChart = ({ width, height, originalWidth }) => {
141
153
  x={halfWidth - barWidth}
142
154
  y={y + config.barHeight / 2}
143
155
  fill={textFits ? groupOne.labelColor : '#000'}
156
+ fontSize={labelFontSize}
144
157
  >
145
158
  {formatNumber(d[groupOne.dataKey], 'left')}
146
159
  </Text>
@@ -168,7 +181,10 @@ const PairedBarChart = ({ width, height, originalWidth }) => {
168
181
  const totalheight = (Number(config.barSpace) + barHeight + borderWidth) * data.length
169
182
  config.heights.horizontal = totalheight
170
183
  // check if text fits inside of the bar including suffix/prefix,comma,fontSize ..etc
171
- const textWidth = getTextWidth(formatNumber(d[groupTwo.dataKey], 'left'))
184
+ const textWidth = getTextWidth(
185
+ formatNumber(d[groupTwo.dataKey], 'left'),
186
+ `normal ${labelFontSize}px sans-serif`
187
+ )
172
188
  const isTextFits = textWidth < barWidth - 5 // minus padding dx(5)
173
189
 
174
190
  return (
@@ -207,6 +223,7 @@ const PairedBarChart = ({ width, height, originalWidth }) => {
207
223
  x={halfWidth + barWidth}
208
224
  y={y + config.barHeight / 2}
209
225
  fill={isTextFits ? groupTwo.labelColor : '#000'}
226
+ fontSize={labelFontSize}
210
227
  >
211
228
  {formatNumber(d[groupTwo.dataKey], 'left')}
212
229
  </Text>
@@ -268,7 +268,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
268
268
  const textOpacity = shouldMute ? 0.3 : 1
269
269
 
270
270
  return (
271
- <Group key={key} className={`slice-${key}`}>
271
+ <Group key={key} className={`slice-${CSS.escape(String(key))}`}>
272
272
  {/* ── the slice */}
273
273
  <animated.path
274
274
  d={to([styles.startAngle, styles.endAngle], (start: number, end: number) =>
@@ -0,0 +1,78 @@
1
+ import React, { useContext, useMemo } from 'react'
2
+ import { Group } from '@visx/group'
3
+ import { Line } from '@visx/shape'
4
+ import { Text } from '@visx/text'
5
+ import { genPoints, getTextAnchor } from './helpers'
6
+ import ConfigContext from '../../ConfigContext'
7
+
8
+ type RadarAxisProps = {
9
+ radius: number
10
+ strokeColor?: string
11
+ strokeWidth?: number
12
+ labelColor?: string
13
+ fontSize?: number
14
+ }
15
+
16
+ /**
17
+ * RadarAxis renders the axis lines from center to edge
18
+ * and the axis labels at each vertex
19
+ */
20
+ const RadarAxis: React.FC<RadarAxisProps> = ({
21
+ radius,
22
+ strokeColor = '#999999',
23
+ strokeWidth = 1,
24
+ labelColor = '#333333',
25
+ fontSize = 14
26
+ }) => {
27
+ const { config } = useContext(ConfigContext)
28
+
29
+ const radarConfig = config.radar
30
+ const labelOffset = Number(radarConfig?.axisLabelOffset) || 15
31
+
32
+ const labels = useMemo(() => {
33
+ if (!config.series || config.series.length === 0) return []
34
+ return config.series.map(s => s.dataKey).filter(Boolean)
35
+ }, [config.series])
36
+ const axisCount = labels.length
37
+ const axisPoints = genPoints(axisCount, radius)
38
+ const labelPoints = genPoints(axisCount, radius + labelOffset)
39
+
40
+ return (
41
+ <Group className='radar-axis'>
42
+ {/* Axis lines from center to edge */}
43
+ {axisPoints.map((point, i) => (
44
+ <Line
45
+ key={`axis-line-${i}`}
46
+ from={{ x: 0, y: 0 }}
47
+ to={point}
48
+ stroke={strokeColor}
49
+ strokeWidth={strokeWidth}
50
+ strokeOpacity={0.7}
51
+ />
52
+ ))}
53
+
54
+ {/* Axis labels */}
55
+ {labelPoints.map((point, i) => {
56
+ const angle = (i * 2 * Math.PI) / axisCount - Math.PI / 2
57
+ const textAnchor = getTextAnchor(angle)
58
+
59
+ return (
60
+ <Text
61
+ key={`axis-label-${i}`}
62
+ x={point.x}
63
+ y={point.y}
64
+ textAnchor={textAnchor}
65
+ verticalAnchor='middle'
66
+ fill={labelColor}
67
+ fontSize={fontSize}
68
+ fontFamily='sans-serif'
69
+ >
70
+ {labels[i]}
71
+ </Text>
72
+ )
73
+ })}
74
+ </Group>
75
+ )
76
+ }
77
+
78
+ export default RadarAxis
@@ -0,0 +1,298 @@
1
+ import React, { useContext, useMemo, useEffect } from 'react'
2
+ import { Group } from '@visx/group'
3
+ import { scaleLinear } from '@visx/scale'
4
+ import { useTooltip, TooltipWithBounds } from '@visx/tooltip'
5
+ import { localPoint } from '@visx/event'
6
+
7
+ import ConfigContext, { ChartDispatchContext } from '../../ConfigContext'
8
+ import { useTooltip as useCoveTooltip } from '../../hooks/useTooltip'
9
+ import { useChartHoverAnalytics } from '../../hooks/useChartHoverAnalytics'
10
+ import { handleChartAriaLabels } from '../../helpers/handleChartAriaLabels'
11
+ import getViewport from '@cdc/core/helpers/getViewport'
12
+ import { isMobileHeightViewport } from '@cdc/core/helpers/viewports'
13
+ import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
14
+
15
+ import RadarGrid from './RadarGrid'
16
+ import RadarAxis from './RadarAxis'
17
+ import RadarPolygon from './RadarPolygon'
18
+
19
+ type TooltipData = {
20
+ entityName: string
21
+ values: { label: string; value: number }[]
22
+ dataXPosition: number
23
+ dataYPosition: number
24
+ }
25
+
26
+ type RadarChartProps = {
27
+ parentWidth?: number
28
+ parentHeight?: number
29
+ interactionLabel?: string
30
+ }
31
+
32
+ const RadarChart = React.forwardRef<SVGSVGElement, RadarChartProps>((props, ref) => {
33
+ const { interactionLabel = '' } = props
34
+
35
+ const {
36
+ transformedData: data,
37
+ config,
38
+ colorScale,
39
+ seriesHighlight,
40
+ isDraggingAnnotation,
41
+ formatNumber
42
+ } = useContext(ConfigContext)
43
+
44
+ const dispatch = useContext(ChartDispatchContext)
45
+
46
+ const { tooltipData, showTooltip, hideTooltip, tooltipOpen, tooltipLeft, tooltipTop } = useTooltip<TooltipData>()
47
+
48
+ const { handleTooltipMouseOff } = useCoveTooltip({
49
+ xScale: false,
50
+ yScale: false,
51
+ showTooltip,
52
+ hideTooltip,
53
+ interactionLabel
54
+ })
55
+
56
+ const { handleChartMouseEnter, handleChartMouseLeave } = useChartHoverAnalytics({
57
+ config,
58
+ interactionLabel
59
+ })
60
+
61
+ // Get radar config with defaults
62
+ const radarConfig = config.radar || {
63
+ gridRings: 5,
64
+ showGridRings: true,
65
+ gridRingStyle: 'polygons',
66
+ scaleMin: 0,
67
+ scaleMax: '',
68
+ showFill: false,
69
+ fillOpacity: 0.3,
70
+ showPoints: true,
71
+ pointRadius: 4,
72
+ strokeWidth: 2,
73
+ axisLabelOffset: 15
74
+ }
75
+
76
+ // Extract dimension keys from series
77
+ const dimensionKeys = useMemo(() => {
78
+ if (!config.series || config.series.length === 0) return []
79
+ return config.series.map(s => s.dataKey).filter(Boolean)
80
+ }, [config.series])
81
+
82
+ // Extract entity identifier key
83
+ const entityKey = config.xAxis?.dataKey || ''
84
+
85
+ // Process data into entities with their dimension values
86
+ const entities = useMemo(() => {
87
+ if (!data || data.length === 0 || dimensionKeys.length === 0) return []
88
+
89
+ return data.map(row => {
90
+ const entityName = String(row[entityKey] || '')
91
+ const values = dimensionKeys.map(key => {
92
+ const val = parseFloat(row[key])
93
+ return isNaN(val) ? 0 : val
94
+ })
95
+ return { name: entityName, values }
96
+ })
97
+ }, [data, entityKey, dimensionKeys])
98
+
99
+ // Calculate scale max value
100
+ const scaleMax = useMemo(() => {
101
+ if (radarConfig.scaleMax && radarConfig.scaleMax !== '') {
102
+ return Number(radarConfig.scaleMax)
103
+ }
104
+ // Auto-calculate from data
105
+ let max = 0
106
+ entities.forEach(entity => {
107
+ entity.values.forEach(val => {
108
+ if (val > max) max = val
109
+ })
110
+ })
111
+ // Add 10% padding
112
+ return Math.ceil(max * 1.1)
113
+ }, [entities, radarConfig.scaleMax])
114
+
115
+ const scaleMin = Number(radarConfig.scaleMin) || 0
116
+
117
+ // Chart dimensions
118
+ const width = props.parentWidth || 400
119
+ const derivedViewport = getViewport(width)
120
+ const useMobileHeight = config.heights?.mobileVertical && isMobileHeightViewport(derivedViewport)
121
+ const height = Number(useMobileHeight ? config.heights.mobileVertical : config.heights?.vertical) || 400
122
+ const margin = { top: 40, right: 40, bottom: 40, left: 40 }
123
+
124
+ const innerWidth = width - margin.left - margin.right
125
+ const innerHeight = height - margin.top - margin.bottom
126
+ const radius = Math.min(innerWidth, innerHeight) / 2 - Number(radarConfig.axisLabelOffset || 15)
127
+
128
+ const centerX = width / 2
129
+ const centerY = height / 2
130
+
131
+ // Create radius scale
132
+ const radiusScale = useMemo(() => {
133
+ return scaleLinear<number>({
134
+ domain: [scaleMin, scaleMax],
135
+ range: [0, radius]
136
+ })
137
+ }, [scaleMin, scaleMax, radius])
138
+
139
+ // Handle tooltip display
140
+ const handlePolygonHover = (e: React.MouseEvent, entityData: { entityName: string; values: number[] }) => {
141
+ const tooltipValues = entityData.values.map((val, i) => ({
142
+ label: dimensionKeys[i],
143
+ value: val
144
+ }))
145
+
146
+ const point = localPoint(e) || { x: 0, y: 0 }
147
+
148
+ showTooltip({
149
+ tooltipData: {
150
+ entityName: entityData.entityName,
151
+ values: tooltipValues,
152
+ dataXPosition: point.x,
153
+ dataYPosition: point.y
154
+ },
155
+ tooltipLeft: point.x,
156
+ tooltipTop: point.y
157
+ })
158
+ }
159
+
160
+ const handlePolygonLeave = () => {
161
+ hideTooltip()
162
+ }
163
+
164
+ // Get color for entity
165
+ const getEntityColor = (entityName: string, index: number): string => {
166
+ if (colorScale) {
167
+ return colorScale(entityName)
168
+ }
169
+ // Fallback colors
170
+ const fallbackColors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f']
171
+ return fallbackColors[index % fallbackColors.length]
172
+ }
173
+
174
+ // Update runtime seriesKeys for legend
175
+ useEffect(() => {
176
+ if (!dispatch || entities.length === 0) {
177
+ return
178
+ }
179
+
180
+ // Ensure seriesKeys are unique and stable
181
+ const seriesKeys = Array.from(new Set(entities.map(e => e.name)))
182
+ const previousSeriesKeys = (config.runtime?.seriesKeys || []) as string[]
183
+
184
+ const hasSameKeys =
185
+ previousSeriesKeys.length === seriesKeys.length &&
186
+ previousSeriesKeys.every((key, index) => key === seriesKeys[index])
187
+
188
+ if (hasSameKeys) {
189
+ return
190
+ }
191
+
192
+ dispatch({
193
+ type: 'SET_RUNTIME',
194
+ payload: {
195
+ ...config.runtime,
196
+ seriesKeys,
197
+ seriesLabelsAll: seriesKeys
198
+ }
199
+ })
200
+ }, [entities, dispatch, config.runtime])
201
+
202
+ // Validation
203
+ if (!config.xAxis?.dataKey) {
204
+ return (
205
+ <div className='radar-chart-error' style={{ padding: '20px', color: '#666' }}>
206
+ Radar chart requires an entity identifier. Please set the X Axis data key in the configuration.
207
+ </div>
208
+ )
209
+ }
210
+
211
+ if (dimensionKeys.length < 3) {
212
+ return (
213
+ <div className='radar-chart-error' style={{ padding: '20px', color: '#666' }}>
214
+ Radar chart requires at least 3 dimensions. Please add more series in the configuration.
215
+ </div>
216
+ )
217
+ }
218
+
219
+ if (entities.length === 0) {
220
+ return (
221
+ <div className='radar-chart-error' style={{ padding: '20px', color: '#666' }}>
222
+ No data available for radar chart.
223
+ </div>
224
+ )
225
+ }
226
+
227
+ return (
228
+ <ErrorBoundary component='RadarChart'>
229
+ <svg
230
+ ref={ref}
231
+ width={width}
232
+ height={height}
233
+ className='radar-chart'
234
+ role='img'
235
+ aria-label={handleChartAriaLabels(config)}
236
+ onMouseEnter={handleChartMouseEnter}
237
+ onMouseLeave={() => {
238
+ handleTooltipMouseOff()
239
+ handleChartMouseLeave()
240
+ }}
241
+ >
242
+ <Group top={centerY} left={centerX}>
243
+ {/* Grid rings */}
244
+ <RadarGrid radius={radius} axisCount={dimensionKeys.length} />
245
+
246
+ {/* Axis lines and labels */}
247
+ <RadarAxis radius={radius} />
248
+
249
+ {/* Data polygons */}
250
+ {entities.map((entity, index) => {
251
+ const isHighlighted = seriesHighlight.length === 0 || seriesHighlight.includes(entity.name)
252
+ const shouldMute = config.legend?.behavior === 'highlight' && seriesHighlight.length > 0 && !isHighlighted
253
+
254
+ // Skip rendering if isolate behavior and not highlighted
255
+ if (config.legend?.behavior === 'isolate' && seriesHighlight.length > 0 && !isHighlighted) {
256
+ return null
257
+ }
258
+
259
+ return (
260
+ <RadarPolygon
261
+ key={entity.name}
262
+ values={entity.values}
263
+ scale={radiusScale}
264
+ color={getEntityColor(entity.name, index)}
265
+ entityName={entity.name}
266
+ shouldMute={shouldMute}
267
+ onMouseEnter={handlePolygonHover}
268
+ onMouseLeave={handlePolygonLeave}
269
+ />
270
+ )
271
+ })}
272
+ </Group>
273
+ </svg>
274
+
275
+ {/* Tooltip */}
276
+ {!isDraggingAnnotation && tooltipData && tooltipOpen && (
277
+ <>
278
+ <style>{`.tooltip {background-color: rgba(255,255,255, ${
279
+ (config.tooltips?.opacity || 90) / 100
280
+ }) !important`}</style>
281
+ <TooltipWithBounds className='tooltip cdc-open-viz-module' left={tooltipLeft} top={tooltipTop}>
282
+ <div style={{ fontWeight: 'bold', marginBottom: '8px' }}>{tooltipData.entityName}</div>
283
+ <ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
284
+ {tooltipData.values.map((item, index) => (
285
+ <li key={index} style={{ padding: '2px 0' }}>
286
+ <span style={{ fontWeight: 500 }}>{item.label}:</span>{' '}
287
+ {formatNumber ? formatNumber(item.value, 'left') : item.value}
288
+ </li>
289
+ ))}
290
+ </ul>
291
+ </TooltipWithBounds>
292
+ </>
293
+ )}
294
+ </ErrorBoundary>
295
+ )
296
+ })
297
+
298
+ export default RadarChart
@@ -0,0 +1,64 @@
1
+ import React, { useContext } from 'react'
2
+ import { Group } from '@visx/group'
3
+ import { genPoints, pointsToString } from './helpers'
4
+ import ConfigContext from '../../ConfigContext'
5
+
6
+ type RadarGridProps = {
7
+ radius: number
8
+ axisCount: number
9
+ strokeColor?: string
10
+ strokeWidth?: number
11
+ }
12
+
13
+ /**
14
+ * RadarGrid renders concentric rings (either circles or polygons)
15
+ * for the radar chart background grid
16
+ */
17
+ const RadarGrid: React.FC<RadarGridProps> = ({ radius, axisCount, strokeColor = '#e0e0e0', strokeWidth = 1 }) => {
18
+ const { config } = useContext(ConfigContext)
19
+
20
+ const radarConfig = config.radar
21
+ const levels = Number(radarConfig?.gridRings) || 5
22
+ const showGrid = radarConfig?.showGridRings ?? true
23
+ const gridStyle = radarConfig?.gridRingStyle ?? 'polygons'
24
+ if (!showGrid) return null
25
+
26
+ return (
27
+ <Group className='radar-grid'>
28
+ {[...new Array(levels)].map((_, i) => {
29
+ const levelRadius = ((i + 1) * radius) / levels
30
+ const key = `grid-level-${i}`
31
+
32
+ if (gridStyle === 'circles') {
33
+ return (
34
+ <circle
35
+ key={key}
36
+ cx={0}
37
+ cy={0}
38
+ r={levelRadius}
39
+ fill='none'
40
+ stroke={strokeColor}
41
+ strokeWidth={strokeWidth}
42
+ strokeOpacity={0.5}
43
+ />
44
+ )
45
+ }
46
+
47
+ // Polygon grid
48
+ const points = genPoints(axisCount, levelRadius)
49
+ return (
50
+ <polygon
51
+ key={key}
52
+ points={pointsToString(points)}
53
+ fill='none'
54
+ stroke={strokeColor}
55
+ strokeWidth={strokeWidth}
56
+ strokeOpacity={0.5}
57
+ />
58
+ )
59
+ })}
60
+ </Group>
61
+ )
62
+ }
63
+
64
+ export default RadarGrid
@@ -0,0 +1,91 @@
1
+ import React, { useState, useContext } from 'react'
2
+ import { Group } from '@visx/group'
3
+ import { genPolygonPoints, pointsToString } from './helpers'
4
+ import ConfigContext from '../../ConfigContext'
5
+
6
+ type RadarPolygonProps = {
7
+ values: number[]
8
+ scale: (n: number) => number
9
+ color: string
10
+ entityName: string
11
+ shouldMute: boolean
12
+ onMouseEnter?: (e: React.MouseEvent, data: { entityName: string; values: number[] }) => void
13
+ onMouseLeave?: () => void
14
+ }
15
+
16
+ /**
17
+ * RadarPolygon renders a single data polygon on the radar chart
18
+ * with optional data points at vertices
19
+ */
20
+ const RadarPolygon: React.FC<RadarPolygonProps> = ({
21
+ values,
22
+ scale,
23
+ color,
24
+ entityName,
25
+ shouldMute,
26
+ onMouseEnter,
27
+ onMouseLeave
28
+ }) => {
29
+ const { config } = useContext(ConfigContext)
30
+
31
+ const radarConfig = config.radar
32
+ const showFill = radarConfig?.showFill ?? false
33
+ const fillOpacity = showFill ? Number(radarConfig?.fillOpacity ?? 0.3) : 0
34
+ const strokeWidth = Number(radarConfig?.strokeWidth) || 2
35
+ const showPoints = radarConfig?.showPoints ?? true
36
+ const pointRadius = Number(radarConfig?.pointRadius) || 4
37
+ const [isHovered, setIsHovered] = useState(false)
38
+
39
+ const points = genPolygonPoints(values, scale)
40
+ const polygonString = pointsToString(points)
41
+
42
+ const opacity = shouldMute ? 0.2 : isHovered ? Math.min(1, fillOpacity + 0.2) : fillOpacity
43
+ const currentStrokeWidth = isHovered ? strokeWidth + 1 : strokeWidth
44
+
45
+ const handleMouseEnter = (e: React.MouseEvent) => {
46
+ setIsHovered(true)
47
+ onMouseEnter?.(e, { entityName, values })
48
+ }
49
+
50
+ const handleMouseLeave = () => {
51
+ setIsHovered(false)
52
+ onMouseLeave?.()
53
+ }
54
+
55
+ return (
56
+ <Group className={`radar-polygon radar-polygon-${CSS.escape(String(entityName))}`}>
57
+ {/* Data polygon */}
58
+ <polygon
59
+ points={polygonString}
60
+ fill={color}
61
+ fillOpacity={opacity}
62
+ stroke={color}
63
+ strokeWidth={currentStrokeWidth}
64
+ strokeOpacity={shouldMute ? 0.3 : 1}
65
+ style={{ cursor: 'pointer', transition: 'opacity 0.2s ease' }}
66
+ onMouseEnter={handleMouseEnter}
67
+ onMouseLeave={handleMouseLeave}
68
+ />
69
+
70
+ {/* Data points at vertices */}
71
+ {showPoints &&
72
+ points.map((point, i) => (
73
+ <circle
74
+ key={`point-${i}`}
75
+ cx={point.x}
76
+ cy={point.y}
77
+ r={isHovered ? pointRadius + 1 : pointRadius}
78
+ fill={color}
79
+ stroke='#fff'
80
+ strokeWidth={1}
81
+ opacity={shouldMute ? 0.3 : 1}
82
+ style={{ cursor: 'pointer', transition: 'r 0.2s ease' }}
83
+ onMouseEnter={handleMouseEnter}
84
+ onMouseLeave={handleMouseLeave}
85
+ />
86
+ ))}
87
+ </Group>
88
+ )
89
+ }
90
+
91
+ export default RadarPolygon
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Helper utilities for Radar Chart point generation
3
+ * Following visx patterns for radar/spider chart calculations
4
+ */
5
+
6
+ export type RadarPoint = {
7
+ x: number
8
+ y: number
9
+ }
10
+
11
+ /**
12
+ * Generate angles for each axis, starting at 12 o'clock position
13
+ * @param length - number of axes
14
+ * @returns array of angle objects in degrees
15
+ */
16
+ export const genAngles = (length: number): { angle: number }[] => {
17
+ return [...new Array(length + 1)].map((_, i) => ({
18
+ angle: i * (360 / length) - 90 // Start at 12 o'clock (-90 degrees)
19
+ }))
20
+ }
21
+
22
+ /**
23
+ * Generate points for polygon vertices at a given radius
24
+ * @param length - number of vertices
25
+ * @param radius - distance from center
26
+ * @returns array of x,y coordinates
27
+ */
28
+ export const genPoints = (length: number, radius: number): RadarPoint[] => {
29
+ return [...new Array(length)].map((_, i) => {
30
+ const angle = (i * 2 * Math.PI) / length - Math.PI / 2 // Start at 12 o'clock
31
+ return {
32
+ x: radius * Math.cos(angle),
33
+ y: radius * Math.sin(angle)
34
+ }
35
+ })
36
+ }
37
+
38
+ /**
39
+ * Generate polygon points from data values
40
+ * @param dataArray - array of numeric values for each axis
41
+ * @param scale - scale function to map values to radius
42
+ * @returns array of x,y coordinates for the polygon
43
+ */
44
+ export const genPolygonPoints = (dataArray: number[], scale: (n: number) => number): RadarPoint[] => {
45
+ return dataArray.map((value, i) => {
46
+ const angle = (i * 2 * Math.PI) / dataArray.length - Math.PI / 2
47
+ const scaledRadius = scale(value)
48
+ return {
49
+ x: scaledRadius * Math.cos(angle),
50
+ y: scaledRadius * Math.sin(angle)
51
+ }
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Convert RadarPoint array to SVG polygon points string
57
+ * @param points - array of RadarPoint objects
58
+ * @returns string formatted for SVG polygon points attribute
59
+ */
60
+ export const pointsToString = (points: RadarPoint[]): string => {
61
+ return points.map(p => `${p.x},${p.y}`).join(' ')
62
+ }
63
+
64
+ /**
65
+ * Calculate the optimal text anchor based on angle position
66
+ * @param angle - angle in radians
67
+ * @returns 'start' | 'middle' | 'end'
68
+ */
69
+ export const getTextAnchor = (angle: number): 'start' | 'middle' | 'end' => {
70
+ const degrees = (angle * 180) / Math.PI
71
+ const normalizedDegrees = ((degrees % 360) + 360) % 360
72
+
73
+ if (normalizedDegrees > 60 && normalizedDegrees < 120) {
74
+ return 'middle'
75
+ }
76
+ if (normalizedDegrees > 240 && normalizedDegrees < 300) {
77
+ return 'middle'
78
+ }
79
+ if (normalizedDegrees >= 120 && normalizedDegrees <= 240) {
80
+ return 'end'
81
+ }
82
+ return 'start'
83
+ }
@@ -0,0 +1,3 @@
1
+ import RadarChart from './RadarChart'
2
+
3
+ export default RadarChart