@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.
- package/CLAUDE.local.md +79 -0
- package/LICENSE +201 -0
- package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
- package/dist/cdcchart.js +54742 -49796
- package/examples/data/data-with-metadata.json +10 -0
- package/examples/default.json +378 -0
- package/examples/feature/__data__/horizon-chart-data.json +373 -0
- package/examples/feature/annotations/index.json +3 -6
- package/examples/feature/horizon/horizon-chart.json +395 -0
- package/examples/feature/pie/planet-pie-example-config.json +2 -1
- package/examples/line-chart-states.json +1085 -0
- package/examples/metadata-variables.json +58 -0
- package/examples/private/123.json +694 -0
- package/examples/private/anchor-issue.json +4094 -0
- package/examples/private/backwards-slider.json +10430 -0
- package/examples/private/georgia.csv +160 -0
- package/examples/private/timeline-data.json +1 -0
- package/examples/private/timeline.json +389 -0
- package/examples/radar-chart-simple.json +133 -0
- package/examples/radar-chart.json +148 -0
- package/index.html +1 -31
- package/package.json +57 -59
- package/src/CdcChart.tsx +8 -4
- package/src/CdcChartComponent.tsx +398 -284
- package/src/_stories/Chart.Anchors.stories.tsx +10 -0
- package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
- package/src/_stories/Chart.CI.stories.tsx +13 -0
- package/src/_stories/Chart.Combo.stories.tsx +17 -0
- package/src/_stories/Chart.CustomColors.stories.tsx +78 -0
- package/src/_stories/Chart.Defaults.stories.tsx +95 -0
- package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
- package/src/_stories/Chart.Filters.stories.tsx +4 -0
- package/src/_stories/Chart.Forecast.stories.tsx +4 -0
- package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
- package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
- package/src/_stories/Chart.Patterns.stories.tsx +4 -0
- package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
- package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
- package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
- package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
- package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
- package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
- package/src/_stories/Chart.stories.tsx +72 -1
- package/src/_stories/Chart.tooltip.stories.tsx +7 -0
- package/src/_stories/ChartAnnotation.stories.tsx +10 -0
- package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
- package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
- package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
- package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
- package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
- package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
- package/src/_stories/ChartBrush.stories.tsx +7 -0
- package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
- package/src/_stories/ChartEditor.stories.tsx +7 -0
- package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
- package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
- package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
- package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
- package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
- package/src/_stories/_mock/brush_continuous.json +86 -0
- package/src/_stories/_mock/brush_date_large.json +176 -0
- package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
- package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
- package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
- package/src/_stories/_mock/paired-bar-abbr.json +421 -0
- package/src/_stories/_mock/pie_custom_colors.json +268 -0
- package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +14 -20
- package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
- package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
- package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
- package/src/components/Annotations/components/AnnotationList.styles.css +12 -18
- package/src/components/Annotations/components/AnnotationList.tsx +5 -4
- package/src/components/Annotations/components/findNearestDatum.ts +75 -85
- package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
- package/src/components/Axis/BottomAxis.tsx +277 -0
- package/src/components/Axis/LeftAxis.tsx +404 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
- package/src/components/Axis/PairedBarAxis.tsx +192 -0
- package/src/components/Axis/README.md +94 -0
- package/src/components/Axis/RightAxis.tsx +108 -0
- package/src/components/Axis/axis.constants.ts +21 -0
- package/src/components/Axis/index.ts +7 -0
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
- package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
- package/src/components/BarChart/components/BarChart.tsx +7 -1
- package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
- package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
- package/src/components/BarChart/helpers/useBarChart.ts +3 -0
- package/src/components/Brush/BrushSelector.tsx +155 -22
- package/src/components/Brush/MiniChartPreview.tsx +133 -21
- package/src/components/EditorPanel/EditorPanel.tsx +81 -54
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +67 -29
- package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +120 -2
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +25 -43
- package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -3
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +66 -43
- package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
- package/src/components/EditorPanel/editor-panel.scss +1 -1
- package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
- package/src/components/ForestPlot/ForestPlot.tsx +26 -22
- package/src/components/HorizonChart/HorizonChart.tsx +131 -0
- package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
- package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
- package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
- package/src/components/HorizonChart/index.tsx +3 -0
- package/src/components/Legend/Legend.Component.tsx +52 -4
- package/src/components/Legend/Legend.tsx +1 -1
- package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
- package/src/components/Legend/LegendValueRange.tsx +77 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +16 -2
- package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
- package/src/components/LineChart/helpers/README.md +292 -0
- package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
- package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
- package/src/components/LineChart/index.tsx +44 -8
- package/src/components/LinearChart/README.md +109 -0
- package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
- package/src/components/LinearChart/linearChart.constants.ts +84 -0
- package/src/components/LinearChart/tests/LinearChart.test.tsx +278 -0
- package/src/components/LinearChart/tests/mockConfigContext.ts +131 -0
- package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
- package/src/components/LinearChart.tsx +268 -1057
- package/src/components/PieChart/PieChart.tsx +20 -5
- package/src/components/RadarChart/RadarAxis.tsx +78 -0
- package/src/components/RadarChart/RadarChart.tsx +298 -0
- package/src/components/RadarChart/RadarGrid.tsx +64 -0
- package/src/components/RadarChart/RadarPolygon.tsx +91 -0
- package/src/components/RadarChart/helpers.ts +83 -0
- package/src/components/RadarChart/index.tsx +3 -0
- package/src/components/Regions/components/Regions.tsx +6 -6
- package/src/components/Sankey/components/Sankey.tsx +3 -3
- package/src/components/Sankey/sankey.scss +1 -1
- package/src/components/SmallMultiples/SmallMultiples.css +5 -5
- package/src/components/Sparkline/index.scss +4 -2
- package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
- package/src/data/initial-state.js +37 -15
- package/src/data/legacy-defaults.ts +18 -0
- package/src/helpers/abbreviateNumber.ts +24 -17
- package/src/helpers/getChartPatternId.ts +17 -0
- package/src/helpers/getExcludedData.ts +4 -0
- package/src/helpers/getMinMax.ts +16 -2
- package/src/helpers/handleChartAriaLabels.ts +19 -19
- package/src/helpers/handleLineType.ts +22 -18
- package/src/helpers/seriesColumnSettings.ts +114 -0
- package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
- package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
- package/src/hooks/useProgrammaticTooltip.ts +23 -2
- package/src/hooks/useRightAxis.ts +14 -0
- package/src/hooks/useScales.ts +99 -56
- package/src/hooks/useTooltip.tsx +23 -3
- package/src/scss/main.scss +157 -79
- package/src/selectors/README.md +68 -0
- package/src/store/chart.reducer.ts +2 -0
- package/src/test/CdcChart.test.jsx +2 -2
- package/src/types/ChartConfig.ts +22 -0
- package/src/types/ChartContext.ts +1 -0
- package/src/types/Horizon.ts +64 -0
- package/tests/fixtures/chart-config-with-metadata.json +29 -0
- package/tests/fixtures/data-with-metadata.json +10 -0
- package/preview.html +0 -1616
- 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
|
-
|
|
176
|
-
|
|
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('.
|
|
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
|
|
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
|
+
}
|