@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import parse from 'html-react-parser'
|
|
2
|
-
import React from 'react'
|
|
2
|
+
import React, { useMemo } from 'react'
|
|
3
3
|
import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend'
|
|
4
4
|
import { PatternLines, PatternCircles, PatternWaves } from '@visx/pattern'
|
|
5
5
|
import LegendShape from '@cdc/core/components/LegendShape'
|
|
@@ -17,6 +17,8 @@ import { DimensionsType } from '@cdc/core/types/Dimensions'
|
|
|
17
17
|
import { isLegendWrapViewport } from '@cdc/core/helpers/viewports'
|
|
18
18
|
import LegendLineShape from './LegendLine.Shape'
|
|
19
19
|
import LegendGroup from './LegendGroup'
|
|
20
|
+
import LegendValueRange from './LegendValueRange'
|
|
21
|
+
import { getHorizonLayerColors, getHorizonMaxValue } from '../../components/HorizonChart/helpers/getHorizonLayerColors'
|
|
20
22
|
import { getSeriesWithData } from '../../helpers/dataHelpers'
|
|
21
23
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
22
24
|
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
@@ -28,6 +30,7 @@ interface LegendProps {
|
|
|
28
30
|
config: ChartConfig
|
|
29
31
|
currentViewport: ViewportSize
|
|
30
32
|
formatLabels: (labels: Label[]) => Label[]
|
|
33
|
+
formatNumber?: (value: number, axis?: string) => string
|
|
31
34
|
highlight: Function
|
|
32
35
|
handleShowAll: Function
|
|
33
36
|
ref: React.Ref<() => void>
|
|
@@ -48,6 +51,7 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
48
51
|
handleShowAll,
|
|
49
52
|
currentViewport,
|
|
50
53
|
formatLabels,
|
|
54
|
+
formatNumber,
|
|
51
55
|
skipId = 'legend',
|
|
52
56
|
dimensions,
|
|
53
57
|
transformedData: data,
|
|
@@ -60,7 +64,10 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
60
64
|
const { series } = runtime
|
|
61
65
|
|
|
62
66
|
const seriesWithData = getSeriesWithData(config)
|
|
63
|
-
|
|
67
|
+
// For Radar charts, seriesWithData contains dimension keys but legend shows entity names
|
|
68
|
+
// so we skip the series filter for radar charts
|
|
69
|
+
const isRadarChart = config.visualizationType === 'Radar'
|
|
70
|
+
const dontFilterLegendItems = !series.length || legend.unified || !seriesWithData.length || isRadarChart
|
|
64
71
|
|
|
65
72
|
const isLegendBottom =
|
|
66
73
|
legend?.position === 'bottom' ||
|
|
@@ -74,6 +81,29 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
74
81
|
const { HighLightedBarUtils } = useHighlightedBars(config)
|
|
75
82
|
let highLightedLegendItems = HighLightedBarUtils.findDuplicates(config.highlightedBarValues)
|
|
76
83
|
|
|
84
|
+
const horizonLegendData = useMemo(() => {
|
|
85
|
+
if (config.visualizationType !== 'Horizon Chart') {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
const numLayers = config.horizon?.numLayers || 4
|
|
89
|
+
const runtimeSeriesKeys = config.runtime?.seriesKeys
|
|
90
|
+
const seriesKeys =
|
|
91
|
+
(Array.isArray(runtimeSeriesKeys) && runtimeSeriesKeys.length > 0
|
|
92
|
+
? runtimeSeriesKeys
|
|
93
|
+
: config.series?.map(s => s.dataKey)) || []
|
|
94
|
+
const maxValue = getHorizonMaxValue(data, seriesKeys)
|
|
95
|
+
const layerColors = getHorizonLayerColors(config, numLayers)
|
|
96
|
+
|
|
97
|
+
return { numLayers, maxValue, layerColors }
|
|
98
|
+
}, [
|
|
99
|
+
config.visualizationType,
|
|
100
|
+
config.horizon?.numLayers,
|
|
101
|
+
config.runtime?.seriesKeys,
|
|
102
|
+
config.series,
|
|
103
|
+
config.general?.palette?.name,
|
|
104
|
+
data
|
|
105
|
+
])
|
|
106
|
+
|
|
77
107
|
if (!legend) return null
|
|
78
108
|
return (
|
|
79
109
|
<aside
|
|
@@ -99,6 +129,20 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
99
129
|
/>
|
|
100
130
|
<LegendGroup formatLabels={formatLabels} />
|
|
101
131
|
|
|
132
|
+
{/* Value Range Legend for Horizon Chart (and future chart types) */}
|
|
133
|
+
{horizonLegendData && (
|
|
134
|
+
<LegendValueRange
|
|
135
|
+
maxValue={horizonLegendData.maxValue}
|
|
136
|
+
numRanges={horizonLegendData.numLayers}
|
|
137
|
+
colors={horizonLegendData.layerColors}
|
|
138
|
+
formatNumber={formatNumber}
|
|
139
|
+
innerClasses={innerClasses}
|
|
140
|
+
shape={config.legend.style === 'boxes' ? 'square' : 'circle'}
|
|
141
|
+
onClick={undefined}
|
|
142
|
+
reverseLabelOrder={config.legend.reverseLabelOrder}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
|
|
102
146
|
<LegendOrdinal scale={colorScale} itemDirection='row' labelMargin='0 20px 0 0' shapeMargin='0 10px 0'>
|
|
103
147
|
{labels => {
|
|
104
148
|
return (
|
|
@@ -130,8 +174,12 @@ const Legend: React.FC<LegendProps> = forwardRef(
|
|
|
130
174
|
} else className.push('highlighted')
|
|
131
175
|
}
|
|
132
176
|
|
|
133
|
-
if (
|
|
134
|
-
|
|
177
|
+
if (
|
|
178
|
+
config.legend.style === 'gradient' ||
|
|
179
|
+
config.legend.groupBy ||
|
|
180
|
+
config.visualizationType === 'Horizon Chart'
|
|
181
|
+
) {
|
|
182
|
+
return null
|
|
135
183
|
}
|
|
136
184
|
|
|
137
185
|
return (
|
|
@@ -3,7 +3,6 @@ import ConfigContext from '../../ConfigContext'
|
|
|
3
3
|
import LegendComponent from './Legend.Component'
|
|
4
4
|
import { createFormatLabels } from './helpers/createFormatLabels'
|
|
5
5
|
|
|
6
|
-
/* eslint-disable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */
|
|
7
6
|
const Legend = forwardRef((props, ref) => {
|
|
8
7
|
// prettier-ignore
|
|
9
8
|
const {
|
|
@@ -42,6 +41,7 @@ const Legend = forwardRef((props, ref) => {
|
|
|
42
41
|
handleShowAll={handleShowAll}
|
|
43
42
|
currentViewport={currentViewport}
|
|
44
43
|
formatLabels={createLegendLabels}
|
|
44
|
+
formatNumber={formatNumber}
|
|
45
45
|
interactionLabel={interactionLabel}
|
|
46
46
|
/>
|
|
47
47
|
</Fragment>
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
margin-bottom: 0.5rem;
|
|
7
7
|
|
|
8
8
|
.group-item .visx-legend-label {
|
|
9
|
-
font-weight: 400;
|
|
10
9
|
font-size: 0.889rem;
|
|
10
|
+
font-weight: 400;
|
|
11
11
|
margin-bottom: 0.5rem;
|
|
12
12
|
}
|
|
13
13
|
.group-label {
|
|
14
|
-
font-weight: 500;
|
|
15
14
|
font-family: Nunito, sans-serif;
|
|
16
15
|
font-size: 1rem;
|
|
16
|
+
font-weight: 500;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
grid-gap: 10px;
|
|
23
23
|
|
|
24
24
|
&.group-item {
|
|
25
|
+
align-items: flex-start;
|
|
25
26
|
display: flex;
|
|
26
27
|
flex-direction: column;
|
|
27
|
-
align-items: flex-start;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
& .inactive {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
transition: 0.2s all;
|
|
33
33
|
}
|
|
34
34
|
& .highlighted {
|
|
35
|
+
border-radius: 1px;
|
|
35
36
|
outline: 1px solid #005ea2;
|
|
36
37
|
outline-offset: 5px;
|
|
37
|
-
border-radius: 1px;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { LegendItem, LegendLabel } from '@visx/legend'
|
|
3
|
+
import LegendShape from '@cdc/core/components/LegendShape'
|
|
4
|
+
import { generateValueRanges, GenerateValueRangesOptions, ValueRange } from './helpers/generateValueRanges'
|
|
5
|
+
|
|
6
|
+
export type LegendValueRangeProps = GenerateValueRangesOptions & {
|
|
7
|
+
colors: string[]
|
|
8
|
+
shape?: 'circle' | 'square'
|
|
9
|
+
onClick?: (index: number, range: ValueRange) => void
|
|
10
|
+
innerClasses?: string[]
|
|
11
|
+
reverseLabelOrder?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* LegendValueRange - Generic component for displaying value range legends
|
|
16
|
+
*
|
|
17
|
+
* Used by Horizon Charts to show layer intensity ranges (e.g., 1-100, 101-200).
|
|
18
|
+
* Can be reused by other chart types that need binned value legends.
|
|
19
|
+
*/
|
|
20
|
+
const LegendValueRange: React.FC<LegendValueRangeProps> = ({
|
|
21
|
+
minValue = 0,
|
|
22
|
+
maxValue,
|
|
23
|
+
numRanges,
|
|
24
|
+
distribution = 'equal',
|
|
25
|
+
formatNumber,
|
|
26
|
+
colors,
|
|
27
|
+
shape = 'square',
|
|
28
|
+
onClick,
|
|
29
|
+
innerClasses = ['legend-container__inner'],
|
|
30
|
+
reverseLabelOrder = false
|
|
31
|
+
}) => {
|
|
32
|
+
const ranges = generateValueRanges({ minValue, maxValue, numRanges, distribution, formatNumber })
|
|
33
|
+
|
|
34
|
+
if (ranges.length === 0) return null
|
|
35
|
+
|
|
36
|
+
// Reverse both ranges and colors when reverseLabelOrder is true
|
|
37
|
+
const displayRanges = reverseLabelOrder ? [...ranges].reverse() : ranges
|
|
38
|
+
const displayColors = reverseLabelOrder ? [...colors].reverse() : colors
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={innerClasses.join(' ')}>
|
|
42
|
+
{displayRanges.map((range, i) => {
|
|
43
|
+
const color = displayColors[i % displayColors.length]
|
|
44
|
+
const isClickable = typeof onClick === 'function'
|
|
45
|
+
const className = ['legend-item', `legend-text--range-${i}`, !isClickable && 'not-clickable'].filter(Boolean)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<LegendItem
|
|
49
|
+
className={className.join(' ')}
|
|
50
|
+
tabIndex={isClickable ? 0 : -1}
|
|
51
|
+
key={`legend-range-${i}`}
|
|
52
|
+
onKeyDown={e => {
|
|
53
|
+
if (isClickable && e.key === 'Enter') {
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
onClick(i, range)
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
onClick={e => {
|
|
59
|
+
if (isClickable) {
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
onClick(i, range)
|
|
62
|
+
}
|
|
63
|
+
}}
|
|
64
|
+
role={isClickable ? 'button' : undefined}
|
|
65
|
+
>
|
|
66
|
+
<LegendShape shape={shape} fill={color} />
|
|
67
|
+
<LegendLabel align='left' className='m-0'>
|
|
68
|
+
{range.label}
|
|
69
|
+
</LegendLabel>
|
|
70
|
+
</LegendItem>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default LegendValueRange
|
|
@@ -17,7 +17,8 @@ import { FaStar } from 'react-icons/fa'
|
|
|
17
17
|
import { Label } from '../../../types/Label'
|
|
18
18
|
import { ColorScale, TransformedData } from '../../../types/ChartContext'
|
|
19
19
|
import { ChartConfig } from '../../../types/ChartConfig'
|
|
20
|
-
import
|
|
20
|
+
import cloneDeep from 'lodash/cloneDeep'
|
|
21
|
+
import sortBy from 'lodash/sortBy'
|
|
21
22
|
import { scaleSequential } from 'd3-scale'
|
|
22
23
|
import { interpolateRgbBasis } from 'd3-interpolate'
|
|
23
24
|
import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
|
|
@@ -173,7 +174,7 @@ export const createFormatLabels =
|
|
|
173
174
|
|
|
174
175
|
const sortVertical = labels =>
|
|
175
176
|
legend.verticalSorted
|
|
176
|
-
?
|
|
177
|
+
? sortBy(cloneDeep(labels), label => {
|
|
177
178
|
const match = label.datum?.match(/-?\d+(\.\d+)?/)
|
|
178
179
|
return match ? parseFloat(match[0]) : Number.MAX_SAFE_INTEGER
|
|
179
180
|
})
|
|
@@ -342,6 +343,19 @@ export const createFormatLabels =
|
|
|
342
343
|
return reverseLabels(seriesLabels)
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
// For Radar charts, use the entity names from runtime.seriesKeys (set from xAxis.dataKey values)
|
|
347
|
+
// not the series names (which are the dimension keys)
|
|
348
|
+
if (visualizationType === 'Radar') {
|
|
349
|
+
const entityNames = runtime.seriesKeys || []
|
|
350
|
+
const radarLabels = entityNames.map((val, i) => ({
|
|
351
|
+
datum: val,
|
|
352
|
+
index: i,
|
|
353
|
+
text: String(val),
|
|
354
|
+
value: colorScale(val)
|
|
355
|
+
}))
|
|
356
|
+
return reverseLabels(radarLabels)
|
|
357
|
+
}
|
|
358
|
+
|
|
345
359
|
if (config.series.some(item => item.name)) {
|
|
346
360
|
const uniqueLabels = Array.from(new Set(config.series.map(d => d.name || d.dataKey))).map((val, i) => ({
|
|
347
361
|
datum: val,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateValueRanges - Creates value range bins for legend display
|
|
3
|
+
*
|
|
4
|
+
* Supports equal interval distribution with scaffolding for future quantile support.
|
|
5
|
+
* Ranges are inclusive with no overlap, starting at minValue or 0 (e.g., 1-100, 101-200).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type RangeDistribution = 'equal' | 'quantile'
|
|
9
|
+
|
|
10
|
+
export type ValueRange = {
|
|
11
|
+
min: number
|
|
12
|
+
max: number
|
|
13
|
+
label: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type GenerateValueRangesOptions = {
|
|
17
|
+
minValue?: number
|
|
18
|
+
maxValue: number
|
|
19
|
+
numRanges: number
|
|
20
|
+
distribution?: RangeDistribution
|
|
21
|
+
formatNumber?: (value: number, axis?: string) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generates an array of value ranges for legend display
|
|
26
|
+
*/
|
|
27
|
+
export const generateValueRanges = ({
|
|
28
|
+
minValue = 0,
|
|
29
|
+
maxValue,
|
|
30
|
+
numRanges,
|
|
31
|
+
distribution = 'equal',
|
|
32
|
+
formatNumber
|
|
33
|
+
}: GenerateValueRangesOptions): ValueRange[] => {
|
|
34
|
+
if (numRanges <= 0 || maxValue <= minValue) {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ranges: ValueRange[] = []
|
|
39
|
+
|
|
40
|
+
if (distribution === 'equal') {
|
|
41
|
+
const rangeSize = (maxValue - minValue) / numRanges
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < numRanges; i++) {
|
|
44
|
+
// Calculate raw boundaries
|
|
45
|
+
const rawMin = minValue + i * rangeSize
|
|
46
|
+
const rawMax = minValue + (i + 1) * rangeSize
|
|
47
|
+
|
|
48
|
+
// For display:
|
|
49
|
+
// - First range starts at floor of minValue
|
|
50
|
+
// - Subsequent ranges start at previous max + 1 (monotonic constraint)
|
|
51
|
+
// - Last range ends exactly at ceil of maxValue
|
|
52
|
+
let displayMin: number
|
|
53
|
+
let displayMax: number
|
|
54
|
+
|
|
55
|
+
if (i === 0) {
|
|
56
|
+
displayMin = Math.floor(rawMin)
|
|
57
|
+
} else {
|
|
58
|
+
// Start at previous range's max + 1 to avoid overlap
|
|
59
|
+
displayMin = ranges[i - 1].max + 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (i === numRanges - 1) {
|
|
63
|
+
// Last range ends exactly at maxValue
|
|
64
|
+
displayMax = Math.ceil(maxValue)
|
|
65
|
+
} else {
|
|
66
|
+
displayMax = Math.floor(rawMax)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ensure min <= max (can happen with very small ranges)
|
|
70
|
+
if (displayMin > displayMax) {
|
|
71
|
+
displayMax = displayMin
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Format numbers if formatter provided
|
|
75
|
+
const formattedMin = formatNumber ? formatNumber(displayMin, 'left') : displayMin.toLocaleString()
|
|
76
|
+
const formattedMax = formatNumber ? formatNumber(displayMax, 'left') : displayMax.toLocaleString()
|
|
77
|
+
|
|
78
|
+
ranges.push({
|
|
79
|
+
min: displayMin,
|
|
80
|
+
max: displayMax,
|
|
81
|
+
label: displayMin === displayMax ? formattedMin : `${formattedMin}–${formattedMax}`
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
} else if (distribution === 'quantile') {
|
|
85
|
+
// Scaffolding for future quantile-based distribution
|
|
86
|
+
// Would require passing in the actual data values to calculate percentiles
|
|
87
|
+
// Currently, silently fall back to equal interval distribution
|
|
88
|
+
return generateValueRanges({ minValue, maxValue, numRanges, distribution: 'equal', formatNumber })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ranges
|
|
92
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# Line Chart Label Positioning Algorithm
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This algorithm intelligently positions data point labels on line charts to prevent overlap with the chart lines themselves. It uses a quadrant-based system that analyzes line segment angles to determine optimal label placement.
|
|
6
|
+
|
|
7
|
+
The algorithm is based on standard unit circle angle measurements (0° = right/east, 90° = up/north, 180° = left/west, 270° = down/south) and accounts for SVG's inverted y-axis.
|
|
8
|
+
|
|
9
|
+
## Algorithm Design
|
|
10
|
+
|
|
11
|
+
### Quadrant System
|
|
12
|
+
|
|
13
|
+
Line segments are classified into quadrants based on their angle in the standard unit circle:
|
|
14
|
+
|
|
15
|
+
- **Quadrant 1 (Q1)**: 1° - 89° (steep upward slope, right and up)
|
|
16
|
+
- **Quadrant 2 (Q2)**: 91° - 179° (gentle downward slope, left and up)
|
|
17
|
+
- **Quadrant 3 (Q3)**: 181° - 269° (steep downward slope, left and down)
|
|
18
|
+
- **Quadrant 4 (Q4)**: 271° - 359° (gentle upward slope, right and down)
|
|
19
|
+
|
|
20
|
+
### Constants
|
|
21
|
+
|
|
22
|
+
- **Vertical Offset**: 9px (0.5rem) - used for vertical spacing
|
|
23
|
+
- **Horizontal Offset**:
|
|
24
|
+
- **4.5px** (0.25rem) for first/last points
|
|
25
|
+
- **9px** (0.5rem) for middle points (more separation needed)
|
|
26
|
+
- **Near X-Axis Threshold**: ≤20px from the bottom of the chart
|
|
27
|
+
|
|
28
|
+
### Data Point Classification
|
|
29
|
+
|
|
30
|
+
1. **First Point**: Only has an **ending segment** (line going OUT of the point, shown in pink)
|
|
31
|
+
2. **Last Point**: Only has a **starting segment** (line coming INTO the point, shown in purple)
|
|
32
|
+
3. **Middle Points**: Have both **starting** and **ending** segments
|
|
33
|
+
|
|
34
|
+
## Positioning Rules
|
|
35
|
+
|
|
36
|
+
### First Data Point Rules
|
|
37
|
+
|
|
38
|
+
Only has ending segment (line going out):
|
|
39
|
+
|
|
40
|
+
| Ending Segment Range | Near X-Axis (≤20px) | Position |
|
|
41
|
+
|---------------------|---------------------|----------|
|
|
42
|
+
| 180°–269° (Q3) | Any | 9px above |
|
|
43
|
+
| 91°–179° (Q2) | NO | 9px below |
|
|
44
|
+
| 91°–179° (Q2) | YES | 9px above, 4.5px left |
|
|
45
|
+
| 269°–360° (Q4) | Any | 9px above |
|
|
46
|
+
| Other | Any | 9px above (default) |
|
|
47
|
+
|
|
48
|
+
### Last Data Point Rules
|
|
49
|
+
|
|
50
|
+
Only has starting segment (line coming in):
|
|
51
|
+
|
|
52
|
+
| Starting Segment Range | Near X-Axis (≤20px) | Position |
|
|
53
|
+
|-----------------------|---------------------|----------|
|
|
54
|
+
| 269°–360° (Q4) | Any (even near x-axis) | 9px above |
|
|
55
|
+
| 1°–89° (Q1) | NO | 9px below |
|
|
56
|
+
| 0°–89° (Q1) | YES | 9px above, 4.5px right |
|
|
57
|
+
| Other | Any | 9px above (default) |
|
|
58
|
+
|
|
59
|
+
### Middle Data Point Rules
|
|
60
|
+
|
|
61
|
+
Has both starting and ending segments:
|
|
62
|
+
|
|
63
|
+
#### Starting Q1 + Ending Q2 (Peak/Local Maximum)
|
|
64
|
+
|
|
65
|
+
| Angle Between Segments | Near X-Axis | Additional Condition | Position |
|
|
66
|
+
|------------------------|-------------|---------------------|----------|
|
|
67
|
+
| 1°–179° | NO | - | 9px below |
|
|
68
|
+
| ≥135° | YES | - | 9px above (centered) |
|
|
69
|
+
| <135° | YES | Ending angle ≥68° | 9px above, 9px right |
|
|
70
|
+
| <135° | YES | Ending angle <68° | 9px above, 9px left |
|
|
71
|
+
|
|
72
|
+
#### Starting Q4 + Ending Q3
|
|
73
|
+
|
|
74
|
+
| Angle Between Segments | Position |
|
|
75
|
+
|------------------------|----------|
|
|
76
|
+
| 0°–180° | 9px above |
|
|
77
|
+
|
|
78
|
+
#### Starting Q4 + Ending Q2
|
|
79
|
+
|
|
80
|
+
| Angle Between Segments | Position |
|
|
81
|
+
|------------------------|----------|
|
|
82
|
+
| 92°–269° | 9px above, 9px left |
|
|
83
|
+
|
|
84
|
+
#### Starting Q1 + Ending Q3
|
|
85
|
+
|
|
86
|
+
| Angle Between Segments | Position |
|
|
87
|
+
|------------------------|----------|
|
|
88
|
+
| 92°–269° | 9px above, 9px right |
|
|
89
|
+
|
|
90
|
+
#### Default for All Other Middle Point Cases
|
|
91
|
+
|
|
92
|
+
Position: **9px above** (centered)
|
|
93
|
+
|
|
94
|
+
## API Reference
|
|
95
|
+
|
|
96
|
+
### Primary Function
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
function getLabelPositionForDataPoint(
|
|
100
|
+
dataPoints: Point[],
|
|
101
|
+
dataIndex: number,
|
|
102
|
+
chartHeight: number
|
|
103
|
+
): LabelOffset
|
|
104
|
+
|
|
105
|
+
interface Point {
|
|
106
|
+
x: number // SVG x-coordinate
|
|
107
|
+
y: number // SVG y-coordinate
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface LabelOffset {
|
|
111
|
+
dx: number // Horizontal offset (positive = right)
|
|
112
|
+
dy: number // Vertical offset (positive = down)
|
|
113
|
+
textAnchor?: 'start' | 'middle' | 'end'
|
|
114
|
+
verticalAnchor?: 'start' | 'middle' | 'end'
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Alternative Function (Direct Angle Input)
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
function calculateLabelOffset(
|
|
122
|
+
pointIndex: number,
|
|
123
|
+
pointY: number,
|
|
124
|
+
startingSegmentAngle: number | null, // null for first point
|
|
125
|
+
endingSegmentAngle: number | null, // null for last point
|
|
126
|
+
xAxisY: number
|
|
127
|
+
): { offsetX: number; offsetY: number }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Parameters:**
|
|
131
|
+
- `pointY`: Y-coordinate of the point in SVG coordinates
|
|
132
|
+
- `startingSegmentAngle`: Angle of line segment coming INTO point (null for first point)
|
|
133
|
+
- `endingSegmentAngle`: Angle of line segment going OUT OF point (null for last point)
|
|
134
|
+
- `xAxisY`: Y-coordinate of x-axis for "near axis" calculation
|
|
135
|
+
- Returns: `offsetX` (positive = right), `offsetY` (positive = down)
|
|
136
|
+
|
|
137
|
+
### Utility Functions
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// Calculate angle between two points (returns 0°-360°)
|
|
141
|
+
function calculateAngle(fromPoint: Point, toPoint: Point): number
|
|
142
|
+
|
|
143
|
+
// Determine which quadrant an angle belongs to
|
|
144
|
+
function getQuadrant(angle: number): Quadrant
|
|
145
|
+
|
|
146
|
+
// Calculate angle between two segments
|
|
147
|
+
function calculateAngleBetweenSegments(
|
|
148
|
+
startingSegmentAngle: number,
|
|
149
|
+
endingSegmentAngle: number
|
|
150
|
+
): number
|
|
151
|
+
|
|
152
|
+
// Check if point is near x-axis (≤20px from bottom)
|
|
153
|
+
function isNearXAxis(yPosition: number, chartHeight: number): boolean
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Usage Examples
|
|
157
|
+
|
|
158
|
+
### Basic Integration
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { getLabelPositionForDataPoint } from './labelPositioning'
|
|
162
|
+
|
|
163
|
+
// Build array of point coordinates from your data
|
|
164
|
+
const dataPoints = sortedData
|
|
165
|
+
.filter(item => isNumber(item[seriesKey]))
|
|
166
|
+
.map(item => ({
|
|
167
|
+
x: xScale(item.xValue),
|
|
168
|
+
y: yScale(item.yValue)
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
// For each data point, calculate label position
|
|
172
|
+
dataPoints.forEach((point, index) => {
|
|
173
|
+
const labelOffset = getLabelPositionForDataPoint(
|
|
174
|
+
dataPoints,
|
|
175
|
+
index,
|
|
176
|
+
Number(chartHeight)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Render label with offset
|
|
180
|
+
<Text
|
|
181
|
+
x={point.x + labelOffset.dx}
|
|
182
|
+
y={point.y + labelOffset.dy}
|
|
183
|
+
textAnchor={labelOffset.textAnchor || 'middle'}
|
|
184
|
+
verticalAnchor={labelOffset.verticalAnchor || 'middle'}
|
|
185
|
+
>
|
|
186
|
+
{formatNumber(data[index].value)}
|
|
187
|
+
</Text>
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Using Pre-Calculated Angles
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import { calculateLabelOffset } from './labelPositioning'
|
|
195
|
+
|
|
196
|
+
// If you already have angles calculated
|
|
197
|
+
const startAngle = 45 // Coming from bottom-right
|
|
198
|
+
const endAngle = 135 // Going to top-left
|
|
199
|
+
const pointY = 200
|
|
200
|
+
const xAxisY = 450
|
|
201
|
+
|
|
202
|
+
const offset = calculateLabelOffset(
|
|
203
|
+
5, // Point index (for determining first/last/middle)
|
|
204
|
+
pointY,
|
|
205
|
+
startAngle,
|
|
206
|
+
endAngle,
|
|
207
|
+
xAxisY
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
// offset.offsetX and offset.offsetY contain the pixel offsets
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Testing
|
|
214
|
+
|
|
215
|
+
### Unit Tests
|
|
216
|
+
|
|
217
|
+
Run tests with: `npm test labelPositioning.test.ts`
|
|
218
|
+
|
|
219
|
+
Tests verify:
|
|
220
|
+
- Angle calculations (0°, 90°, 180°, 270°, and diagonals)
|
|
221
|
+
- Quadrant classification
|
|
222
|
+
- Near x-axis detection
|
|
223
|
+
- All first point rules
|
|
224
|
+
- All last point rules
|
|
225
|
+
- All middle point rules including Q1→Q2 special cases
|
|
226
|
+
- Edge cases (vertical/horizontal lines, boundary angles)
|
|
227
|
+
|
|
228
|
+
### Visual Testing in Storybook
|
|
229
|
+
|
|
230
|
+
Navigate to: **Components → Templates → Chart → QuadrantAngles**
|
|
231
|
+
|
|
232
|
+
Available test scenarios:
|
|
233
|
+
- **All Quadrants** - Combined view of all angles
|
|
234
|
+
- **Q1 Steep Upward** - Isolated testing of 1°-89° angles
|
|
235
|
+
- **Q2 Gentle Downward** - Isolated testing of 91°-179° angles
|
|
236
|
+
- **Q3 Steep Downward** - Isolated testing of 181°-269° angles
|
|
237
|
+
- **Q4 Gentle Upward** - Isolated testing of 271°-359° angles
|
|
238
|
+
- **Near Zero Rise** - Edge case near 0°/360°
|
|
239
|
+
- **Near Zero Fall** - Edge case near 180°
|
|
240
|
+
|
|
241
|
+
## Implementation Details
|
|
242
|
+
|
|
243
|
+
### Angle Calculation
|
|
244
|
+
|
|
245
|
+
The algorithm uses `Math.atan2(-dy, dx)` to calculate angles:
|
|
246
|
+
- Negative `dy` accounts for SVG's inverted y-axis
|
|
247
|
+
- Result is normalized to [0, 360) range
|
|
248
|
+
- Standard unit circle convention: 0° = right, 90° = up, 180° = left, 270° = down
|
|
249
|
+
|
|
250
|
+
### Coordinate Systems
|
|
251
|
+
|
|
252
|
+
**SVG Coordinates:**
|
|
253
|
+
- x increases rightward
|
|
254
|
+
- y increases downward (inverted from Cartesian)
|
|
255
|
+
|
|
256
|
+
**Angle Measurement:**
|
|
257
|
+
- Standard unit circle (0° = east, counterclockwise)
|
|
258
|
+
- Algorithm internally converts SVG coordinates to standard angles
|
|
259
|
+
|
|
260
|
+
### Near X-Axis Detection
|
|
261
|
+
|
|
262
|
+
A point is considered "near x-axis" when:
|
|
263
|
+
```typescript
|
|
264
|
+
(chartHeight - pointY) <= 20
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Where `chartHeight` is the bottom of the chart (y-axis maximum in SVG coords).
|
|
268
|
+
|
|
269
|
+
## Performance Considerations
|
|
270
|
+
|
|
271
|
+
1. **Per-Point Calculation**: Algorithm runs for each data point on every render
|
|
272
|
+
2. **Optimization Strategies**:
|
|
273
|
+
- Consider memoizing results for static datasets
|
|
274
|
+
- Pre-calculate all points during data transformation
|
|
275
|
+
- Use `React.useMemo` for data point arrays
|
|
276
|
+
|
|
277
|
+
## Constraints and Limitations
|
|
278
|
+
|
|
279
|
+
1. **No Label-to-Label Collision Detection**: Algorithm only prevents line overlap, not label overlap
|
|
280
|
+
2. **Fixed Offsets**: Doesn't adapt to label text width/height
|
|
281
|
+
3. **Single Series**: Doesn't consider proximity to other series' lines
|
|
282
|
+
4. **Static Rules**: No learning or adaptation based on actual rendered results
|
|
283
|
+
|
|
284
|
+
## Future Enhancements
|
|
285
|
+
|
|
286
|
+
- [ ] Dynamic collision detection between labels
|
|
287
|
+
- [ ] Adaptive offset scaling based on chart dimensions
|
|
288
|
+
- [ ] Support for rotated/angled labels
|
|
289
|
+
- [ ] Label width-aware positioning
|
|
290
|
+
- [ ] Multi-series collision avoidance
|
|
291
|
+
- [ ] Configurable offset constants
|
|
292
|
+
- [ ] Label priority system for dense datasets
|