@cdc/chart 4.26.1 → 4.26.3

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 (173) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  4. package/dist/cdcchart.js +54742 -49796
  5. package/examples/data/data-with-metadata.json +10 -0
  6. package/examples/default.json +378 -0
  7. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  8. package/examples/feature/annotations/index.json +3 -6
  9. package/examples/feature/horizon/horizon-chart.json +395 -0
  10. package/examples/feature/pie/planet-pie-example-config.json +2 -1
  11. package/examples/line-chart-states.json +1085 -0
  12. package/examples/metadata-variables.json +58 -0
  13. package/examples/private/123.json +694 -0
  14. package/examples/private/anchor-issue.json +4094 -0
  15. package/examples/private/backwards-slider.json +10430 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/timeline-data.json +1 -0
  18. package/examples/private/timeline.json +389 -0
  19. package/examples/radar-chart-simple.json +133 -0
  20. package/examples/radar-chart.json +148 -0
  21. package/index.html +1 -31
  22. package/package.json +57 -59
  23. package/src/CdcChart.tsx +8 -4
  24. package/src/CdcChartComponent.tsx +398 -284
  25. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  26. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  27. package/src/_stories/Chart.CI.stories.tsx +13 -0
  28. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  29. package/src/_stories/Chart.CustomColors.stories.tsx +78 -0
  30. package/src/_stories/Chart.Defaults.stories.tsx +95 -0
  31. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  32. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  33. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  34. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  35. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  36. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  37. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  38. package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
  39. package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
  40. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
  41. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  42. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  43. package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
  44. package/src/_stories/Chart.stories.tsx +72 -1
  45. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  46. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  47. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  48. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  49. package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
  50. package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
  51. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  52. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  53. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  54. package/src/_stories/ChartBrush.stories.tsx +7 -0
  55. package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
  56. package/src/_stories/ChartEditor.stories.tsx +7 -0
  57. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  58. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  59. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  60. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  61. package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
  62. package/src/_stories/_mock/brush_continuous.json +86 -0
  63. package/src/_stories/_mock/brush_date_large.json +176 -0
  64. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  65. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  66. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  67. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  68. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  69. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  70. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  71. package/src/_stories/_mock/paired-bar-abbr.json +421 -0
  72. package/src/_stories/_mock/pie_custom_colors.json +268 -0
  73. package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
  74. package/src/components/Annotations/components/AnnotationDraggable.styles.css +14 -20
  75. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  76. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  77. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  78. package/src/components/Annotations/components/AnnotationList.styles.css +12 -18
  79. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  80. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  81. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  82. package/src/components/Axis/BottomAxis.tsx +277 -0
  83. package/src/components/Axis/LeftAxis.tsx +404 -0
  84. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  85. package/src/components/Axis/PairedBarAxis.tsx +192 -0
  86. package/src/components/Axis/README.md +94 -0
  87. package/src/components/Axis/RightAxis.tsx +108 -0
  88. package/src/components/Axis/axis.constants.ts +21 -0
  89. package/src/components/Axis/index.ts +7 -0
  90. package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
  91. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
  92. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
  93. package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
  94. package/src/components/BarChart/components/BarChart.tsx +7 -1
  95. package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
  96. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
  97. package/src/components/BarChart/helpers/useBarChart.ts +3 -0
  98. package/src/components/Brush/BrushSelector.tsx +155 -22
  99. package/src/components/Brush/MiniChartPreview.tsx +133 -21
  100. package/src/components/EditorPanel/EditorPanel.tsx +81 -54
  101. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +67 -29
  102. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
  103. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +120 -2
  104. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +25 -43
  105. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  106. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -3
  107. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +66 -43
  108. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  109. package/src/components/EditorPanel/editor-panel.scss +1 -1
  110. package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
  111. package/src/components/ForestPlot/ForestPlot.tsx +26 -22
  112. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  113. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  114. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  115. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  116. package/src/components/HorizonChart/index.tsx +3 -0
  117. package/src/components/Legend/Legend.Component.tsx +52 -4
  118. package/src/components/Legend/Legend.tsx +1 -1
  119. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
  120. package/src/components/Legend/LegendValueRange.tsx +77 -0
  121. package/src/components/Legend/helpers/createFormatLabels.tsx +16 -2
  122. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  123. package/src/components/LineChart/helpers/README.md +292 -0
  124. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  125. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  126. package/src/components/LineChart/index.tsx +44 -8
  127. package/src/components/LinearChart/README.md +109 -0
  128. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  129. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  130. package/src/components/LinearChart/tests/LinearChart.test.tsx +278 -0
  131. package/src/components/LinearChart/tests/mockConfigContext.ts +131 -0
  132. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  133. package/src/components/LinearChart.tsx +268 -1057
  134. package/src/components/PieChart/PieChart.tsx +20 -5
  135. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  136. package/src/components/RadarChart/RadarChart.tsx +298 -0
  137. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  138. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  139. package/src/components/RadarChart/helpers.ts +83 -0
  140. package/src/components/RadarChart/index.tsx +3 -0
  141. package/src/components/Regions/components/Regions.tsx +6 -6
  142. package/src/components/Sankey/components/Sankey.tsx +3 -3
  143. package/src/components/Sankey/sankey.scss +1 -1
  144. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  145. package/src/components/Sparkline/index.scss +4 -2
  146. package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
  147. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
  148. package/src/data/initial-state.js +37 -15
  149. package/src/data/legacy-defaults.ts +18 -0
  150. package/src/helpers/abbreviateNumber.ts +24 -17
  151. package/src/helpers/getChartPatternId.ts +17 -0
  152. package/src/helpers/getExcludedData.ts +4 -0
  153. package/src/helpers/getMinMax.ts +16 -2
  154. package/src/helpers/handleChartAriaLabels.ts +19 -19
  155. package/src/helpers/handleLineType.ts +22 -18
  156. package/src/helpers/seriesColumnSettings.ts +114 -0
  157. package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
  158. package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
  159. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  160. package/src/hooks/useRightAxis.ts +14 -0
  161. package/src/hooks/useScales.ts +99 -56
  162. package/src/hooks/useTooltip.tsx +23 -3
  163. package/src/scss/main.scss +157 -79
  164. package/src/selectors/README.md +68 -0
  165. package/src/store/chart.reducer.ts +2 -0
  166. package/src/test/CdcChart.test.jsx +2 -2
  167. package/src/types/ChartConfig.ts +22 -0
  168. package/src/types/ChartContext.ts +1 -0
  169. package/src/types/Horizon.ts +64 -0
  170. package/tests/fixtures/chart-config-with-metadata.json +29 -0
  171. package/tests/fixtures/data-with-metadata.json +10 -0
  172. package/preview.html +0 -1616
  173. package/src/components/Annotations/components/helpers/index.tsx +0 -46
@@ -172,8 +172,22 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
172
172
  const domainKeys = isPercentageMode ? dataKeys.filter(k => k !== labelForCalcArea) : dataKeys
173
173
  const numberOfKeys = domainKeys.length
174
174
 
175
- let palette = getPaletteColors(config, colorPalettes)
176
- palette = applyEnhancedColorDistribution(config, palette, numberOfKeys)
175
+ const orderedCustomColors = config.general?.palette?.customColorsOrdered
176
+ const customColors = config.general?.palette?.customColors
177
+ const shouldUseOrderedCustomColors = Array.isArray(orderedCustomColors) && orderedCustomColors.length > 0
178
+
179
+ let palette = shouldUseOrderedCustomColors ? orderedCustomColors : getPaletteColors(config, colorPalettes)
180
+ if (!shouldUseOrderedCustomColors && customColors?.length) {
181
+ palette = customColors
182
+ }
183
+
184
+ while (palette.length > 0 && palette.length < numberOfKeys) {
185
+ palette = palette.concat(palette)
186
+ }
187
+
188
+ palette = shouldUseOrderedCustomColors
189
+ ? palette.slice(0, numberOfKeys)
190
+ : applyEnhancedColorDistribution(config, palette, numberOfKeys)
177
191
 
178
192
  const unknownColor = isPercentageMode
179
193
  ? getComputedStyle(document.documentElement).getPropertyValue('--cool-gray-10').trim()
@@ -206,6 +220,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
206
220
  config.general?.palette?.name,
207
221
  config.general?.palette?.isReversed,
208
222
  config.general?.palette?.customColors,
223
+ config.general?.palette?.customColorsOrdered,
209
224
  config.palette
210
225
  ])
211
226
 
@@ -216,7 +231,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
216
231
 
217
232
  // Make sure the chart is visible if in the editor
218
233
  useEffect(() => {
219
- const element = document.querySelector('.isEditor')
234
+ const element = document.querySelector('.is-editor')
220
235
  if (element) {
221
236
  // parent element is visible
222
237
  setAnimatePie(true)
@@ -268,7 +283,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
268
283
  const textOpacity = shouldMute ? 0.3 : 1
269
284
 
270
285
  return (
271
- <Group key={key} className={`slice-${key}`}>
286
+ <Group key={key} className={`slice-${CSS.escape(String(key))}`}>
272
287
  {/* ── the slice */}
273
288
  <animated.path
274
289
  d={to([styles.startAngle, styles.endAngle], (start: number, end: number) =>
@@ -421,7 +436,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
421
436
  config.tooltips.opacity / 100
422
437
  }) !important`}</style>
423
438
  <TooltipWithBounds
424
- className={'tooltip cdc-open-viz-module'}
439
+ className={'tooltip cove-visualization'}
425
440
  left={tooltipLeft + centerX - radius}
426
441
  top={tooltipTop}
427
442
  >
@@ -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 cove-visualization' 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