@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
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { calculateLabelPosition, calculateAngle, isLowValue } from './labelPositioning'
3
+
4
+ describe('labelPositioning', () => {
5
+ const X_AXIS_Y = 450 // Bottom of chart
6
+ const VERTICAL_OFFSET = 9
7
+ const HORIZONTAL_OFFSET_SMALL = 4.5
8
+ const HORIZONTAL_OFFSET_LARGE = 9
9
+
10
+ describe('calculateAngle', () => {
11
+ it('should calculate 0° for horizontal right', () => {
12
+ const from = { x: 0, y: 100 }
13
+ const to = { x: 100, y: 100 }
14
+ expect(calculateAngle(from, to)).toBeCloseTo(0, 1)
15
+ })
16
+
17
+ it('should calculate 90° for vertical up', () => {
18
+ const from = { x: 100, y: 100 }
19
+ const to = { x: 100, y: 0 }
20
+ expect(calculateAngle(from, to)).toBeCloseTo(90, 1)
21
+ })
22
+
23
+ it('should calculate 180° for horizontal left', () => {
24
+ const from = { x: 100, y: 100 }
25
+ const to = { x: 0, y: 100 }
26
+ expect(calculateAngle(from, to)).toBeCloseTo(180, 1)
27
+ })
28
+
29
+ it('should calculate 270° for vertical down', () => {
30
+ const from = { x: 100, y: 0 }
31
+ const to = { x: 100, y: 100 }
32
+ expect(calculateAngle(from, to)).toBeCloseTo(270, 1)
33
+ })
34
+
35
+ it('should calculate 45° for diagonal up-right', () => {
36
+ const from = { x: 0, y: 100 }
37
+ const to = { x: 100, y: 0 }
38
+ expect(calculateAngle(from, to)).toBeCloseTo(45, 1)
39
+ })
40
+
41
+ it('should calculate 135° for diagonal up-left', () => {
42
+ const from = { x: 100, y: 100 }
43
+ const to = { x: 0, y: 0 }
44
+ expect(calculateAngle(from, to)).toBeCloseTo(135, 1)
45
+ })
46
+ })
47
+
48
+ describe('isLowValue', () => {
49
+ it('should return true when point is ≤20px above x-axis', () => {
50
+ expect(isLowValue(430, 450)).toBe(true) // 20px
51
+ expect(isLowValue(440, 450)).toBe(true) // 10px
52
+ expect(isLowValue(450, 450)).toBe(true) // 0px
53
+ })
54
+
55
+ it('should return false when point is >20px above x-axis', () => {
56
+ expect(isLowValue(429, 450)).toBe(false) // 21px
57
+ expect(isLowValue(400, 450)).toBe(false) // 50px
58
+ expect(isLowValue(350, 450)).toBe(false) // 100px
59
+ })
60
+ })
61
+
62
+ describe('calculateLabelPosition - First Point', () => {
63
+ it('should position above when ending segment is in Q3', () => {
64
+ const pointY = 100
65
+ const endingAngle = 225 // Q3: downward-left
66
+ const result = calculateLabelPosition(null, endingAngle, pointY, X_AXIS_Y)
67
+
68
+ expect(result.offsetX).toBe(0)
69
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
70
+ })
71
+
72
+ it('should position below when ending segment is in Q1 and point is high', () => {
73
+ const pointY = 100 // Far from x-axis
74
+ const endingAngle = 45 // Q1: upward-right
75
+ const result = calculateLabelPosition(null, endingAngle, pointY, X_AXIS_Y)
76
+
77
+ expect(result.offsetX).toBe(0)
78
+ expect(result.offsetY).toBe(VERTICAL_OFFSET)
79
+ })
80
+
81
+ it('should position above+left when ending segment is in Q1 and point is low', () => {
82
+ const pointY = 440 // Near x-axis (10px)
83
+ const endingAngle = 45 // Q1: upward-right
84
+ const result = calculateLabelPosition(null, endingAngle, pointY, X_AXIS_Y)
85
+
86
+ expect(result.offsetX).toBe(-HORIZONTAL_OFFSET_SMALL)
87
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
88
+ })
89
+
90
+ it('should position above when ending segment is in Q2 or Q4 (default)', () => {
91
+ const pointY = 100
92
+ const endingAngleQ2 = 135 // Q2
93
+ const endingAngleQ4 = 315 // Q4
94
+
95
+ const resultQ2 = calculateLabelPosition(null, endingAngleQ2, pointY, X_AXIS_Y)
96
+ const resultQ4 = calculateLabelPosition(null, endingAngleQ4, pointY, X_AXIS_Y)
97
+
98
+ expect(resultQ2.offsetX).toBe(0)
99
+ expect(resultQ2.offsetY).toBe(-VERTICAL_OFFSET)
100
+ expect(resultQ4.offsetX).toBe(0)
101
+ expect(resultQ4.offsetY).toBe(-VERTICAL_OFFSET)
102
+ })
103
+ })
104
+
105
+ describe('calculateLabelPosition - Last Point', () => {
106
+ it('should position above when starting segment is in Q4', () => {
107
+ const pointY = 100
108
+ const startingAngle = 315 // Q4: downward-right (looking back)
109
+ const result = calculateLabelPosition(startingAngle, null, pointY, X_AXIS_Y)
110
+
111
+ expect(result.offsetX).toBe(0)
112
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
113
+ })
114
+
115
+ it('should position below when starting segment is in Q2 and point is high', () => {
116
+ const pointY = 100 // Far from x-axis
117
+ const startingAngle = 135 // Q2: upward-left (looking back)
118
+ const result = calculateLabelPosition(startingAngle, null, pointY, X_AXIS_Y)
119
+
120
+ expect(result.offsetX).toBe(0)
121
+ expect(result.offsetY).toBe(VERTICAL_OFFSET)
122
+ })
123
+
124
+ it('should position above+right when starting segment is in Q2 and point is low', () => {
125
+ const pointY = 440 // Near x-axis (10px)
126
+ const startingAngle = 135 // Q2: upward-left (looking back)
127
+ const result = calculateLabelPosition(startingAngle, null, pointY, X_AXIS_Y)
128
+
129
+ expect(result.offsetX).toBe(HORIZONTAL_OFFSET_SMALL)
130
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
131
+ })
132
+
133
+ it('should position below when starting segment is in Q1 and point is high', () => {
134
+ const pointY = 100 // Far from x-axis
135
+ const startingAngle = 45 // Q1: upward-right (looking back)
136
+ const result = calculateLabelPosition(startingAngle, null, pointY, X_AXIS_Y)
137
+
138
+ expect(result.offsetX).toBe(0)
139
+ expect(result.offsetY).toBe(VERTICAL_OFFSET)
140
+ })
141
+
142
+ it('should position above+right when starting segment is in Q1 and point is low', () => {
143
+ const pointY = 440 // Near x-axis (10px)
144
+ const startingAngle = 45 // Q1: upward-right (looking back)
145
+ const result = calculateLabelPosition(startingAngle, null, pointY, X_AXIS_Y)
146
+
147
+ expect(result.offsetX).toBe(HORIZONTAL_OFFSET_SMALL)
148
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
149
+ })
150
+ })
151
+
152
+ describe('calculateLabelPosition - Middle Point', () => {
153
+ it('should position above when start=Q3 and end=Q4 (valley bottom)', () => {
154
+ const pointY = 100
155
+ const startingAngle = 225 // Q3: looking back to upper-left
156
+ const endingAngle = 315 // Q4: looking forward to lower-right
157
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
158
+
159
+ expect(result.offsetX).toBe(0)
160
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
161
+ })
162
+
163
+ it('should position below when start=Q2 and end=Q1 and point is high', () => {
164
+ const pointY = 100 // Far from x-axis
165
+ const startingAngle = 135 // Q2: looking back to upper-left
166
+ const endingAngle = 45 // Q1: looking forward to upper-right
167
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
168
+
169
+ expect(result.offsetX).toBe(0)
170
+ expect(result.offsetY).toBe(VERTICAL_OFFSET)
171
+ })
172
+
173
+ it('should position above when start=Q2, end=Q1, low-value, angle ≥135°', () => {
174
+ const pointY = 440 // Near x-axis (10px)
175
+ const startingAngle = 160 // Q2
176
+ const endingAngle = 20 // Q1
177
+ // Angle between = 140° (≥135°)
178
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
179
+
180
+ expect(result.offsetX).toBe(0)
181
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
182
+ })
183
+
184
+ it('should position above+right when start=Q2, end=Q1, low-value, angle <135°, ending ≥68°', () => {
185
+ const pointY = 440 // Near x-axis (10px)
186
+ const startingAngle = 100 // Q2
187
+ const endingAngle = 70 // Q1 and ≥68°
188
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
189
+
190
+ expect(result.offsetX).toBe(HORIZONTAL_OFFSET_LARGE)
191
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
192
+ })
193
+
194
+ it('should position above+left when start=Q2, end=Q1, low-value, angle <135°, ending <68°', () => {
195
+ const pointY = 440 // Near x-axis (10px)
196
+ const startingAngle = 100 // Q2
197
+ const endingAngle = 50 // Q1 and <68°
198
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
199
+
200
+ expect(result.offsetX).toBe(-HORIZONTAL_OFFSET_LARGE)
201
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
202
+ })
203
+
204
+ it('should position above+left when start=Q3 and end=Q1', () => {
205
+ const pointY = 100
206
+ const startingAngle = 225 // Q3
207
+ const endingAngle = 45 // Q1
208
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
209
+
210
+ expect(result.offsetX).toBe(-HORIZONTAL_OFFSET_LARGE)
211
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
212
+ })
213
+
214
+ it('should position above+right when start=Q2 and end=Q4', () => {
215
+ const pointY = 100
216
+ const startingAngle = 135 // Q2
217
+ const endingAngle = 315 // Q4
218
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
219
+
220
+ expect(result.offsetX).toBe(HORIZONTAL_OFFSET_LARGE)
221
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
222
+ })
223
+ })
224
+
225
+ describe('Edge cases', () => {
226
+ it('should handle point exactly at x-axis (value=0)', () => {
227
+ const pointY = 450 // Exactly at x-axis
228
+ const startingAngle = 135 // Q2
229
+ const endingAngle = 45 // Q1
230
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
231
+
232
+ // Should still follow low-value rules
233
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
234
+ })
235
+
236
+ it('should return default position above when no specific rule matches', () => {
237
+ const pointY = 100
238
+ const startingAngle = 180 // Q3
239
+ const endingAngle = 180 // Q3 (unusual but possible)
240
+ const result = calculateLabelPosition(startingAngle, endingAngle, pointY, X_AXIS_Y)
241
+
242
+ expect(result.offsetY).toBe(-VERTICAL_OFFSET)
243
+ })
244
+ })
245
+ })
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Line Chart Label Positioning Algorithm
3
+ *
4
+ * Implements quadrant-based label positioning to prevent overlaps with line segments.
5
+ * Uses segment direction analysis and vertical position to determine optimal label placement.
6
+ *
7
+ * Quadrant System (Standard Cartesian):
8
+ * - Q1 (0°-90°): upper-right
9
+ * - Q2 (90°-180°): upper-left
10
+ * - Q3 (180°-270°): lower-left
11
+ * - Q4 (270°-360°): lower-right
12
+ *
13
+ * Angle System: Standard polar coordinates (0° = right/east, counterclockwise)
14
+ * Note: In SVG coordinate space, Y increases downward, so angles are inverted
15
+ *
16
+ * Segment Constraints:
17
+ * - Starting segments (left quadrants) should be in Q2 or Q3
18
+ * - Ending segments (right quadrants) should be in Q1 or Q4
19
+ */
20
+
21
+ // Constants
22
+ const VERTICAL_OFFSET = 9 // 0.5rem in pixels
23
+ const HORIZONTAL_OFFSET_SMALL = 4.5 // 0.25rem in pixels
24
+ const HORIZONTAL_OFFSET_LARGE = 9 // 0.5rem in pixels
25
+ const LOW_VALUE_THRESHOLD = 20 // pixels from x-axis
26
+
27
+ interface Point {
28
+ x: number
29
+ y: number
30
+ }
31
+
32
+ interface LabelOffset {
33
+ dx: number // horizontal offset
34
+ dy: number // vertical offset (negative = up, positive = down)
35
+ textAnchor?: 'start' | 'middle' | 'end'
36
+ verticalAnchor?: 'start' | 'middle' | 'end'
37
+ }
38
+
39
+ /**
40
+ * Determine quadrant for an angle (1-4) using standard Cartesian plane
41
+ * Q1 (0°-90°): upper-right
42
+ * Q2 (90°-180°): upper-left
43
+ * Q3 (180°-270°): lower-left
44
+ * Q4 (270°-360°): lower-right
45
+ */
46
+ function getQuadrant(angle: number): number {
47
+ const normalized = ((angle % 360) + 360) % 360
48
+ if (normalized >= 0 && normalized < 90) return 1
49
+ if (normalized >= 90 && normalized < 180) return 2
50
+ if (normalized >= 180 && normalized < 270) return 3
51
+ if (normalized >= 270 && normalized < 360) return 4
52
+ return 1 // Edge case: 360° maps to 0° → Q1
53
+ }
54
+
55
+ /**
56
+ * Calculate the angle between two segments
57
+ * Returns the absolute angular difference, normalized to 0°-180°
58
+ */
59
+ function getAngleBetweenSegments(startAngle: number, endAngle: number): number {
60
+ let diff = Math.abs(endAngle - startAngle)
61
+ if (diff > 180) {
62
+ diff = 360 - diff
63
+ }
64
+ return diff
65
+ }
66
+
67
+ /**
68
+ * Check if a point is low-value (≤20px above x-axis)
69
+ */
70
+ export function isLowValue(yPosition: number, xAxisY: number): boolean {
71
+ const distanceFromXAxis = xAxisY - yPosition
72
+ return distanceFromXAxis <= LOW_VALUE_THRESHOLD
73
+ }
74
+
75
+ /**
76
+ * Calculate the angle in degrees between two points
77
+ * Returns angle in range [0, 360) using standard polar coordinates:
78
+ * - 0° = right (east)
79
+ * - 90° = up (north in Cartesian, but note SVG y-axis is inverted)
80
+ * - 180° = left (west)
81
+ * - 270° = down (south in Cartesian)
82
+ */
83
+ export function calculateAngle(fromPoint: Point, toPoint: Point): number {
84
+ const dx = toPoint.x - fromPoint.x
85
+ const dy = toPoint.y - fromPoint.y
86
+
87
+ // atan2 returns angle in radians from -PI to PI
88
+ // Negative dy because SVG y-axis is inverted (y increases downward)
89
+ let angleRad = Math.atan2(-dy, dx)
90
+ let angleDeg = angleRad * (180 / Math.PI)
91
+
92
+ // Normalize to [0, 360)
93
+ if (angleDeg < 0) {
94
+ angleDeg += 360
95
+ }
96
+
97
+ return angleDeg
98
+ }
99
+
100
+ /**
101
+ * Main label positioning function
102
+ *
103
+ * Calculates optimal label position based on segment directions and point position
104
+ *
105
+ * @param startingSegmentAngle - Angle from previous point to current (null for first point)
106
+ * @param endingSegmentAngle - Angle from current point to next (null for last point)
107
+ * @param pointY - Y coordinate of current point
108
+ * @param xAxisY - Y coordinate of the x-axis
109
+ * @param value - Optional data value for debugging purposes
110
+ */
111
+ export function calculateLabelPosition(
112
+ startingSegmentAngle: number | null,
113
+ endingSegmentAngle: number | null,
114
+ pointY: number,
115
+ xAxisY: number,
116
+ value?: string | number
117
+ ): { offsetX: number; offsetY: number } {
118
+ const isNearXAxis = isLowValue(pointY, xAxisY)
119
+
120
+ // ===== FIRST POINT (only ending segment: current → next) =====
121
+ if (startingSegmentAngle === null && endingSegmentAngle !== null) {
122
+ const quad = getQuadrant(endingSegmentAngle)
123
+
124
+ // Position label 0.5rem / 9px above point when segment is in Quadrant 3
125
+ if (quad === 3) {
126
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
127
+ }
128
+
129
+ // If the point isn't near the x-axis, position label 0.5rem / 9px below point when segment is in Quadrant 1
130
+ if (quad === 1 && !isNearXAxis) {
131
+ return { offsetX: 0, offsetY: VERTICAL_OFFSET }
132
+ }
133
+
134
+ // If point is 20 pixels or less above x-axis (i.e., it's a low value and close to 0)
135
+ // position label 0.5rem / 9px above and 0.25rem / 4.5px to the left of point
136
+ if (quad === 1 && isNearXAxis) {
137
+ return { offsetX: -HORIZONTAL_OFFSET_SMALL, offsetY: -VERTICAL_OFFSET }
138
+ }
139
+
140
+ // Default for Q2 and Q4: position above
141
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
142
+ }
143
+
144
+ // ===== LAST POINT (only starting segment: previous → current) =====
145
+ // Mirrors the first point logic
146
+ if (startingSegmentAngle !== null && endingSegmentAngle === null) {
147
+ const quad = getQuadrant(startingSegmentAngle)
148
+
149
+ // Q4 mirrors first point Q3: position above
150
+ // This also works for when last data point is near x-axis
151
+ if (quad === 4) {
152
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
153
+ }
154
+
155
+ // Q2 matches the quadrant.txt rules: below if high, above+right if low
156
+ if (quad === 2) {
157
+ if (!isNearXAxis) {
158
+ // Point >20px above x-axis: position below
159
+ return { offsetX: 0, offsetY: VERTICAL_OFFSET }
160
+ } else {
161
+ // Point ≤20px above x-axis: position above + 0.25rem right
162
+ return { offsetX: HORIZONTAL_OFFSET_SMALL, offsetY: -VERTICAL_OFFSET }
163
+ }
164
+ }
165
+
166
+ // Q1: mirror first point Q1 logic for symmetry in Q1 stories
167
+ if (quad === 1) {
168
+ if (!isNearXAxis) {
169
+ // Point >20px above x-axis: position below
170
+ return { offsetX: 0, offsetY: VERTICAL_OFFSET }
171
+ } else {
172
+ // Point ≤20px above x-axis: position above + 0.25rem right
173
+ return { offsetX: HORIZONTAL_OFFSET_SMALL, offsetY: -VERTICAL_OFFSET }
174
+ }
175
+ }
176
+
177
+ // Default for Q3: position above
178
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
179
+ }
180
+
181
+ // ===== MIDDLE POINT (both segments) =====
182
+ if (startingSegmentAngle !== null && endingSegmentAngle !== null) {
183
+ const startQ = getQuadrant(startingSegmentAngle)
184
+ const endQ = getQuadrant(endingSegmentAngle)
185
+ const angleBetween = getAngleBetweenSegments(startingSegmentAngle, endingSegmentAngle)
186
+
187
+ // Position label 0.5rem / 9px above point when starting segment is in Quadrant 3
188
+ // and ending segment is in Quadrant 4 and the angle created is 0°–180°
189
+ if (startQ === 3 && endQ === 4 && angleBetween >= 0 && angleBetween <= 180) {
190
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
191
+ }
192
+
193
+ // If the point isn't near the x-axis, position label 0.5rem / 9px below it
194
+ // when starting segment is in Quadrant 2 and ending segment is in Quadrant 1
195
+ // and the angle created is 1°–179°
196
+ if (startQ === 2 && endQ === 1 && angleBetween >= 1 && angleBetween <= 179 && !isNearXAxis) {
197
+ return { offsetX: 0, offsetY: VERTICAL_OFFSET }
198
+ }
199
+
200
+ // If the point is 20 pixels or less above x-axis (i.e., it's a low value and close to 0)
201
+ // and starting segment is in Quadrant 2 and ending segment is in Quadrant 1
202
+ // then calculate the angle between the 2 segments:
203
+ if (startQ === 2 && endQ === 1 && angleBetween >= 1 && angleBetween <= 179 && isNearXAxis) {
204
+ // If it's 135°–180° then position the label 0.5rem / 9px above the point
205
+ if (angleBetween >= 135 && angleBetween <= 180) {
206
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
207
+ }
208
+
209
+ // If it's less than 135°, calculate the angle created by the ending segment
210
+ if (angleBetween < 135) {
211
+ // If it's 68° or more then position the label 0.5rem / 9px above and 0.5rem / 9px to the right of the point
212
+ if (endingSegmentAngle >= 68) {
213
+ return { offsetX: HORIZONTAL_OFFSET_LARGE, offsetY: -VERTICAL_OFFSET }
214
+ }
215
+ // Otherwise, position the label 0.5rem / 9px above and 0.5rem / 9px to the left of the point
216
+ else {
217
+ return { offsetX: -HORIZONTAL_OFFSET_LARGE, offsetY: -VERTICAL_OFFSET }
218
+ }
219
+ }
220
+ }
221
+
222
+ // Position the label 0.5rem / 9px above and 0.5rem / 9px to the left of the point
223
+ // if starting segment is in Quadrant 3 and ending segment is in Quadrant 1
224
+ // and the angle created is 92°–269°
225
+ if (startQ === 3 && endQ === 1 && angleBetween >= 92 && angleBetween <= 269) {
226
+ return { offsetX: -HORIZONTAL_OFFSET_LARGE, offsetY: -VERTICAL_OFFSET }
227
+ }
228
+
229
+ // Position the label 0.5rem / 9px above and 0.5rem / 9px to the right of the point
230
+ // if starting segment is in Quadrant 2 and ending segment is in Quadrant 4
231
+ // and the angle created is 92°–269°
232
+ if (startQ === 2 && endQ === 4 && angleBetween >= 92 && angleBetween <= 269) {
233
+ return { offsetX: HORIZONTAL_OFFSET_LARGE, offsetY: -VERTICAL_OFFSET }
234
+ }
235
+ }
236
+
237
+ // Default fallback: position above
238
+ return { offsetX: 0, offsetY: -VERTICAL_OFFSET }
239
+ }
240
+
241
+ /**
242
+ * Get text anchor based on horizontal offset
243
+ */
244
+ function getTextAnchor(offsetX: number): 'start' | 'middle' | 'end' {
245
+ if (offsetX > 0) return 'start'
246
+ if (offsetX < 0) return 'end'
247
+ return 'middle'
248
+ }
249
+
250
+ /**
251
+ * Get vertical anchor based on vertical offset
252
+ */
253
+ function getVerticalAnchor(offsetY: number): 'start' | 'middle' | 'end' {
254
+ if (offsetY > 0) return 'start' // Below point
255
+ if (offsetY < 0) return 'end' // Above point
256
+ return 'middle'
257
+ }
258
+
259
+ /**
260
+ * Helper function to get label position for a data point in a series
261
+ */
262
+ export function getLabelPositionForDataPoint(
263
+ dataPoints: Point[],
264
+ dataIndex: number,
265
+ xAxisY: number,
266
+ value?: string | number
267
+ ): LabelOffset {
268
+ const currentPoint = dataPoints[dataIndex]
269
+ const prevPoint = dataPoints[dataIndex - 1]
270
+ const nextPoint = dataPoints[dataIndex + 1]
271
+
272
+ // Calculate angles for segments connected to this point
273
+ // Starting segment: angle looking BACK at where we came from (current → previous)
274
+ // This ensures starting segments are in left quadrants (Q2/Q3)
275
+ const startingSegmentAngle = prevPoint ? calculateAngle(currentPoint, prevPoint) : null
276
+
277
+ // Ending segment: angle looking FORWARD to where we're going (current → next)
278
+ // This ensures ending segments are in right quadrants (Q1/Q4)
279
+ const endingSegmentAngle = nextPoint ? calculateAngle(currentPoint, nextPoint) : null
280
+
281
+ const position = calculateLabelPosition(startingSegmentAngle, endingSegmentAngle, currentPoint.y, xAxisY, value)
282
+
283
+ return {
284
+ dx: position.offsetX,
285
+ dy: position.offsetY,
286
+ textAnchor: getTextAnchor(position.offsetX),
287
+ verticalAnchor: getVerticalAnchor(position.offsetY)
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Legacy compatibility
293
+ * @deprecated Use calculateLabelPosition instead
294
+ */
295
+ export function calculateLabelOffset(
296
+ pointIndex: number,
297
+ pointY: number,
298
+ startingSegmentAngle: number | null,
299
+ endingSegmentAngle: number | null,
300
+ xAxisY: number,
301
+ value?: string | number
302
+ ): { offsetX: number; offsetY: number } {
303
+ return calculateLabelPosition(startingSegmentAngle, endingSegmentAngle, pointY, xAxisY, value)
304
+ }
@@ -15,6 +15,7 @@ import useRightAxis from '../../hooks/useRightAxis'
15
15
 
16
16
  // Local helpers and components
17
17
  import { filterCircles, createStyles, createDataSegments } from './helpers'
18
+ import { getLabelPositionForDataPoint } from './helpers/labelPositioning'
18
19
  import LineChartCircle from './components/LineChart.Circle'
19
20
  import LineChartBumpCircle from './components/LineChart.BumpCircle'
20
21
  import isNumber from '@cdc/core/helpers/isNumber'
@@ -128,20 +129,55 @@ const LineChart = (props: LineChartProps) => {
128
129
  xValue: d[config.xAxis.dataKey],
129
130
  yValue: d[_seriesKey]
130
131
  })
132
+
133
+ // Build array of point coordinates for intelligent label positioning
134
+ const dataPointsForSeries = _data
135
+ .filter(item => isNumber(item[_seriesKey]))
136
+ .map(item => ({
137
+ x: xPos(item),
138
+ y:
139
+ seriesAxis === 'Right'
140
+ ? yScaleRight(getYAxisData(item, _seriesKey))
141
+ : yScale(getYAxisData(item, _seriesKey))
142
+ }))
143
+
144
+ // Find the index in the filtered array (points with valid data only)
145
+ const filteredIndex = dataPointsForSeries.findIndex(
146
+ point =>
147
+ point.x === xPos(d) &&
148
+ point.y ===
149
+ (seriesAxis === 'Right'
150
+ ? yScaleRight(getYAxisData(d, _seriesKey))
151
+ : yScale(getYAxisData(d, _seriesKey)))
152
+ )
153
+
154
+ // Calculate label position
155
+ const labelValue = d[_seriesKey]
156
+ const xAxisY = seriesAxis === 'Right' ? yScaleRight(0) : yScale(0)
157
+
158
+ // Use intelligent label positioning if enabled, otherwise use simple default
159
+ const labelOffset =
160
+ config.general?.useIntelligentLineChartLabels && filteredIndex >= 0
161
+ ? getLabelPositionForDataPoint(dataPointsForSeries, filteredIndex, xAxisY, labelValue)
162
+ : { dx: 0, dy: -9, textAnchor: 'middle' as const, verticalAnchor: 'end' as const }
163
+
164
+ const baseX = xPos(d)
165
+ const baseY =
166
+ seriesAxis === 'Right'
167
+ ? yScaleRight(getYAxisData(d, _seriesKey))
168
+ : yScale(getYAxisData(d, _seriesKey))
169
+
131
170
  return (
132
171
  isNumber(d[_seriesKey]) && (
133
172
  <React.Fragment key={`series-${seriesKey}-point-${dataIndex}`}>
134
- {/* Render label */}
173
+ {/* Render label with intelligent positioning */}
135
174
  {config.labels && (
136
175
  <Text
137
- x={xPos(d)}
138
- y={
139
- seriesAxis === 'Right'
140
- ? yScaleRight(getYAxisData(d, _seriesKey))
141
- : yScale(getYAxisData(d, _seriesKey))
142
- }
176
+ x={baseX + labelOffset.dx}
177
+ y={baseY + labelOffset.dy}
143
178
  fill={'#000'}
144
- textAnchor='middle'
179
+ textAnchor={labelOffset.textAnchor || 'middle'}
180
+ verticalAnchor={labelOffset.verticalAnchor || 'middle'}
145
181
  >
146
182
  {formatNumber(d[_seriesKey], 'left')}
147
183
  </Text>