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