@cdc/chart 4.26.1 → 4.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  4. package/dist/cdcchart.js +54742 -49796
  5. package/examples/data/data-with-metadata.json +10 -0
  6. package/examples/default.json +378 -0
  7. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  8. package/examples/feature/annotations/index.json +3 -6
  9. package/examples/feature/horizon/horizon-chart.json +395 -0
  10. package/examples/feature/pie/planet-pie-example-config.json +2 -1
  11. package/examples/line-chart-states.json +1085 -0
  12. package/examples/metadata-variables.json +58 -0
  13. package/examples/private/123.json +694 -0
  14. package/examples/private/anchor-issue.json +4094 -0
  15. package/examples/private/backwards-slider.json +10430 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/timeline-data.json +1 -0
  18. package/examples/private/timeline.json +389 -0
  19. package/examples/radar-chart-simple.json +133 -0
  20. package/examples/radar-chart.json +148 -0
  21. package/index.html +1 -31
  22. package/package.json +57 -59
  23. package/src/CdcChart.tsx +8 -4
  24. package/src/CdcChartComponent.tsx +398 -284
  25. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  26. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  27. package/src/_stories/Chart.CI.stories.tsx +13 -0
  28. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  29. package/src/_stories/Chart.CustomColors.stories.tsx +78 -0
  30. package/src/_stories/Chart.Defaults.stories.tsx +95 -0
  31. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  32. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  33. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  34. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  35. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  36. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  37. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  38. package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
  39. package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
  40. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
  41. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  42. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  43. package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
  44. package/src/_stories/Chart.stories.tsx +72 -1
  45. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  46. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  47. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  48. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  49. package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
  50. package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
  51. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  52. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  53. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  54. package/src/_stories/ChartBrush.stories.tsx +7 -0
  55. package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
  56. package/src/_stories/ChartEditor.stories.tsx +7 -0
  57. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  58. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  59. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  60. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  61. package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
  62. package/src/_stories/_mock/brush_continuous.json +86 -0
  63. package/src/_stories/_mock/brush_date_large.json +176 -0
  64. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  65. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  66. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  67. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  68. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  69. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  70. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  71. package/src/_stories/_mock/paired-bar-abbr.json +421 -0
  72. package/src/_stories/_mock/pie_custom_colors.json +268 -0
  73. package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
  74. package/src/components/Annotations/components/AnnotationDraggable.styles.css +14 -20
  75. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  76. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  77. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  78. package/src/components/Annotations/components/AnnotationList.styles.css +12 -18
  79. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  80. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  81. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  82. package/src/components/Axis/BottomAxis.tsx +277 -0
  83. package/src/components/Axis/LeftAxis.tsx +404 -0
  84. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  85. package/src/components/Axis/PairedBarAxis.tsx +192 -0
  86. package/src/components/Axis/README.md +94 -0
  87. package/src/components/Axis/RightAxis.tsx +108 -0
  88. package/src/components/Axis/axis.constants.ts +21 -0
  89. package/src/components/Axis/index.ts +7 -0
  90. package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
  91. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
  92. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
  93. package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
  94. package/src/components/BarChart/components/BarChart.tsx +7 -1
  95. package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
  96. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
  97. package/src/components/BarChart/helpers/useBarChart.ts +3 -0
  98. package/src/components/Brush/BrushSelector.tsx +155 -22
  99. package/src/components/Brush/MiniChartPreview.tsx +133 -21
  100. package/src/components/EditorPanel/EditorPanel.tsx +81 -54
  101. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +67 -29
  102. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
  103. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +120 -2
  104. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +25 -43
  105. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  106. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -3
  107. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +66 -43
  108. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  109. package/src/components/EditorPanel/editor-panel.scss +1 -1
  110. package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
  111. package/src/components/ForestPlot/ForestPlot.tsx +26 -22
  112. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  113. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  114. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  115. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  116. package/src/components/HorizonChart/index.tsx +3 -0
  117. package/src/components/Legend/Legend.Component.tsx +52 -4
  118. package/src/components/Legend/Legend.tsx +1 -1
  119. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
  120. package/src/components/Legend/LegendValueRange.tsx +77 -0
  121. package/src/components/Legend/helpers/createFormatLabels.tsx +16 -2
  122. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  123. package/src/components/LineChart/helpers/README.md +292 -0
  124. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  125. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  126. package/src/components/LineChart/index.tsx +44 -8
  127. package/src/components/LinearChart/README.md +109 -0
  128. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  129. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  130. package/src/components/LinearChart/tests/LinearChart.test.tsx +278 -0
  131. package/src/components/LinearChart/tests/mockConfigContext.ts +131 -0
  132. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  133. package/src/components/LinearChart.tsx +268 -1057
  134. package/src/components/PieChart/PieChart.tsx +20 -5
  135. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  136. package/src/components/RadarChart/RadarChart.tsx +298 -0
  137. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  138. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  139. package/src/components/RadarChart/helpers.ts +83 -0
  140. package/src/components/RadarChart/index.tsx +3 -0
  141. package/src/components/Regions/components/Regions.tsx +6 -6
  142. package/src/components/Sankey/components/Sankey.tsx +3 -3
  143. package/src/components/Sankey/sankey.scss +1 -1
  144. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  145. package/src/components/Sparkline/index.scss +4 -2
  146. package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
  147. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
  148. package/src/data/initial-state.js +37 -15
  149. package/src/data/legacy-defaults.ts +18 -0
  150. package/src/helpers/abbreviateNumber.ts +24 -17
  151. package/src/helpers/getChartPatternId.ts +17 -0
  152. package/src/helpers/getExcludedData.ts +4 -0
  153. package/src/helpers/getMinMax.ts +16 -2
  154. package/src/helpers/handleChartAriaLabels.ts +19 -19
  155. package/src/helpers/handleLineType.ts +22 -18
  156. package/src/helpers/seriesColumnSettings.ts +114 -0
  157. package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
  158. package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
  159. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  160. package/src/hooks/useRightAxis.ts +14 -0
  161. package/src/hooks/useScales.ts +99 -56
  162. package/src/hooks/useTooltip.tsx +23 -3
  163. package/src/scss/main.scss +157 -79
  164. package/src/selectors/README.md +68 -0
  165. package/src/store/chart.reducer.ts +2 -0
  166. package/src/test/CdcChart.test.jsx +2 -2
  167. package/src/types/ChartConfig.ts +22 -0
  168. package/src/types/ChartContext.ts +1 -0
  169. package/src/types/Horizon.ts +64 -0
  170. package/tests/fixtures/chart-config-with-metadata.json +29 -0
  171. package/tests/fixtures/data-with-metadata.json +10 -0
  172. package/preview.html +0 -1616
  173. package/src/components/Annotations/components/helpers/index.tsx +0 -46
@@ -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
- const dontFilterLegendItems = !series.length || legend.unified || !seriesWithData.length
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 (config.legend.style === 'gradient' || config.legend.groupBy) {
134
- return <></>
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 _ from 'lodash'
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
- ? _.sortBy(_.cloneDeep(labels), label => {
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