@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.
Files changed (181) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  3. package/dist/cdcchart.js +51401 -50814
  4. package/examples/default.json +378 -0
  5. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  6. package/examples/feature/annotations/index.json +3 -6
  7. package/examples/feature/horizon/horizon-chart.json +395 -0
  8. package/examples/feature/pie/planet-pie-example-config.json +48 -2
  9. package/examples/line-chart-states.json +1085 -0
  10. package/examples/private/123.json +694 -0
  11. package/examples/private/DEV-12100.json +1303 -0
  12. package/examples/private/anchor-issue.json +4094 -0
  13. package/examples/private/backwards-slider.json +10430 -0
  14. package/examples/private/cat-y.json +1235 -0
  15. package/examples/private/data-points.json +228 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/height.json +3915 -0
  18. package/examples/private/links.json +569 -0
  19. package/examples/private/quadrant.txt +30 -0
  20. package/examples/private/test-forecast.json +5510 -0
  21. package/examples/private/timeline-data.json +1 -0
  22. package/examples/private/timeline.json +389 -0
  23. package/examples/private/warming-stripe-test.json +2578 -0
  24. package/examples/private/warming-stripes.json +4763 -0
  25. package/examples/radar-chart-simple.json +133 -0
  26. package/examples/radar-chart.json +148 -0
  27. package/examples/tech-adoption-with-links.json +560 -0
  28. package/index.html +1 -36
  29. package/package.json +59 -60
  30. package/src/CdcChartComponent.tsx +206 -89
  31. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  32. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  33. package/src/_stories/Chart.CI.stories.tsx +13 -0
  34. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  35. package/src/_stories/Chart.CustomColors.stories.tsx +4 -0
  36. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  37. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  38. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  39. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  40. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  41. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  42. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  43. package/src/_stories/Chart.Regions.Categorical.stories.tsx +161 -0
  44. package/src/_stories/Chart.Regions.DateScale.stories.tsx +216 -0
  45. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +312 -0
  46. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  47. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  48. package/src/_stories/Chart.stories.tsx +45 -0
  49. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  50. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  51. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  52. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  53. package/src/_stories/ChartBar.Editor.stories.tsx +11 -6
  54. package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
  55. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  56. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  57. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  58. package/src/_stories/ChartBrush.stories.tsx +57 -0
  59. package/src/_stories/ChartEditor.Editor.stories.tsx +3 -5
  60. package/src/_stories/ChartEditor.stories.tsx +7 -0
  61. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  62. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  63. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  64. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  65. package/src/_stories/TechAdoptionWithLinks.stories.tsx +34 -0
  66. package/src/_stories/_mock/brush_continuous.json +86 -0
  67. package/src/_stories/_mock/brush_date_large.json +176 -0
  68. package/src/_stories/_mock/brush_enabled.json +326 -0
  69. package/src/_stories/_mock/brush_mock.json +2 -69
  70. package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
  71. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  72. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  73. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  74. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  75. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  76. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  77. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  78. package/src/components/Annotations/components/AnnotationDraggable.styles.css +11 -17
  79. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  80. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  81. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  82. package/src/components/Annotations/components/AnnotationList.styles.css +4 -10
  83. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  84. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  85. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  86. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +1 -2
  87. package/src/components/Axis/BottomAxis.tsx +270 -0
  88. package/src/components/Axis/Categorical.Axis.tsx +6 -7
  89. package/src/components/Axis/LeftAxis.tsx +404 -0
  90. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  91. package/src/components/Axis/PairedBarAxis.tsx +186 -0
  92. package/src/components/Axis/README.md +94 -0
  93. package/src/components/Axis/RightAxis.tsx +108 -0
  94. package/src/components/Axis/axis.constants.ts +21 -0
  95. package/src/components/Axis/index.ts +7 -0
  96. package/src/components/BarChart/components/BarChart.Horizontal.tsx +178 -24
  97. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
  98. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
  99. package/src/components/BarChart/components/BarChart.Vertical.tsx +6 -8
  100. package/src/components/BarChart/components/BarChart.tsx +7 -1
  101. package/src/components/BarChart/components/context.tsx +1 -0
  102. package/src/components/BarChart/helpers/useBarChart.ts +14 -2
  103. package/src/components/Brush/BrushSelector.tsx +1390 -0
  104. package/src/components/Brush/MiniChartPreview.tsx +400 -0
  105. package/src/components/DeviationBar.jsx +9 -7
  106. package/src/components/EditorPanel/EditorPanel.tsx +2734 -2595
  107. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +60 -22
  108. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
  109. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +137 -30
  110. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +2 -0
  111. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  112. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +0 -1
  113. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +30 -25
  114. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +42 -28
  115. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  116. package/src/components/EditorPanel/useEditorPermissions.ts +81 -39
  117. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  118. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  119. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  120. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  121. package/src/components/HorizonChart/index.tsx +3 -0
  122. package/src/components/Legend/Legend.Component.tsx +52 -4
  123. package/src/components/Legend/Legend.tsx +4 -3
  124. package/src/components/Legend/LegendValueRange.tsx +77 -0
  125. package/src/components/Legend/helpers/createFormatLabels.tsx +164 -2
  126. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  127. package/src/components/Legend/helpers/index.ts +10 -6
  128. package/src/components/LineChart/helpers/README.md +292 -0
  129. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  130. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  131. package/src/components/LineChart/index.tsx +44 -8
  132. package/src/components/LinearChart/README.md +109 -0
  133. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  134. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  135. package/src/components/LinearChart/tests/LinearChart.test.tsx +201 -0
  136. package/src/components/LinearChart/tests/mockConfigContext.ts +129 -0
  137. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  138. package/src/components/LinearChart.tsx +338 -1082
  139. package/src/components/PairedBarChart.jsx +20 -3
  140. package/src/components/PieChart/PieChart.tsx +1 -1
  141. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  142. package/src/components/RadarChart/RadarChart.tsx +298 -0
  143. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  144. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  145. package/src/components/RadarChart/helpers.ts +83 -0
  146. package/src/components/RadarChart/index.tsx +3 -0
  147. package/src/components/Regions/components/Regions.tsx +365 -122
  148. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
  149. package/src/components/SmallMultiples/SmallMultipleTile.tsx +5 -1
  150. package/src/components/WarmingStripes/WarmingStripes.tsx +230 -0
  151. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
  152. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
  153. package/src/components/WarmingStripes/index.tsx +3 -0
  154. package/src/data/initial-state.js +17 -2
  155. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
  156. package/src/helpers/getExcludedData.ts +4 -0
  157. package/src/helpers/getMinMax.ts +12 -7
  158. package/src/helpers/handleChartAriaLabels.ts +19 -19
  159. package/src/helpers/handleLineType.ts +22 -18
  160. package/src/helpers/sizeHelpers.ts +0 -20
  161. package/src/helpers/smallMultiplesHelpers.ts +1 -1
  162. package/src/hooks/useChartHoverAnalytics.tsx +10 -9
  163. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  164. package/src/hooks/useScales.ts +18 -1
  165. package/src/hooks/useTooltip.tsx +34 -10
  166. package/src/scss/DataTable.scss +0 -4
  167. package/src/scss/main.scss +22 -3
  168. package/src/selectors/README.md +68 -0
  169. package/src/store/chart.reducer.ts +2 -0
  170. package/src/test/CdcChart.test.jsx +1 -1
  171. package/src/types/ChartConfig.ts +21 -0
  172. package/src/types/ChartContext.ts +1 -0
  173. package/src/types/Horizon.ts +64 -0
  174. package/src/types/Label.ts +1 -0
  175. package/src/utils/analyticsTracking.ts +19 -0
  176. package/LICENSE +0 -201
  177. package/src/components/Annotations/components/helpers/index.tsx +0 -46
  178. package/src/components/Brush/BrushChart.tsx +0 -128
  179. package/src/components/Brush/BrushController.tsx +0 -71
  180. package/src/components/Brush/types.tsx +0 -8
  181. package/src/components/BrushChart.tsx +0 -223
@@ -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>
@@ -0,0 +1,109 @@
1
+ # LinearChart
2
+
3
+ The main component for rendering linear visualizations (line charts, bar charts, area charts, etc.).
4
+
5
+ ## File Structure
6
+
7
+ ```
8
+ LinearChart/
9
+ ├── README.md # This file
10
+ ├── linearChart.constants.ts # Shared constants and visualization type definitions
11
+ ├── VisualizationRenderer.tsx # Renders the appropriate chart type
12
+ ├── utils/
13
+ │ └── tickFormatting.ts # Consolidated tick formatting logic (useTickFormatters hook)
14
+ └── tests/
15
+ ├── LinearChart.test.tsx # Component tests
16
+ └── mockConfigContext.ts # Test utilities
17
+ ```
18
+
19
+ ## Main Component
20
+
21
+ **File:** `../LinearChart.tsx` (parent directory)
22
+
23
+ The main LinearChart component orchestrates all the sub-components and handles:
24
+ - Scale calculations (via `useScales` hook)
25
+ - Tooltip management
26
+ - Brush/zoom functionality
27
+ - Animation state
28
+ - Small multiples delegation
29
+
30
+ ## Sub-Components
31
+
32
+ ### VisualizationRenderer
33
+ **File:** `VisualizationRenderer.tsx`
34
+
35
+ A switch component that renders the appropriate visualization based on `visualizationType`:
36
+ - Area Chart (stacked)
37
+ - Bar Chart
38
+ - Line Chart
39
+ - Combo Chart
40
+ - Scatter Plot
41
+ - Box Plot
42
+ - Deviation Bar
43
+ - Bump Chart
44
+ - Forest Plot
45
+ - Paired Bar
46
+ - Spark Line
47
+ - Warming Stripes
48
+
49
+ ### Tick Formatting
50
+ **File:** `utils/tickFormatting.ts`
51
+
52
+ The `useTickFormatters` hook provides consolidated tick formatting for both axes:
53
+ - `handleLeftTickFormatting` - Y-axis tick formatting
54
+ - `handleBottomTickFormatting` - X-axis tick formatting
55
+
56
+ Handles special cases:
57
+ - Logarithmic scales
58
+ - Forest plot data keys
59
+ - Date formatting
60
+ - Number abbreviation
61
+
62
+ ## Constants
63
+ **File:** `linearChart.constants.ts`
64
+
65
+ ```typescript
66
+ // Visualization type string constants
67
+ VISUALIZATION_TYPES
68
+
69
+ // Types that don't show grid lines
70
+ TYPES_WITHOUT_GRID: ['Spark Line', 'Forest Plot', 'Warming Stripes']
71
+
72
+ // Types excluded from standard line chart rendering
73
+ LINE_CHART_EXCLUDED_TYPES
74
+
75
+ // Types that show tooltip guide lines
76
+ TYPES_WITH_TOOLTIP_GUIDES
77
+ ```
78
+
79
+ ## Related Components
80
+
81
+ Axis components are in `../Axis/`:
82
+ - `LeftAxis` - Y-axis rendering
83
+ - `BottomAxis` - X-axis rendering
84
+ - `RightAxis` - Secondary Y-axis (dual-axis charts)
85
+ - `PairedBarAxis` - Specialized axis for paired bar charts
86
+
87
+ ## Context Dependencies
88
+
89
+ LinearChart uses `ConfigContext` for:
90
+ - `config` - Full chart configuration
91
+ - `formatDate`, `formatNumber` - Formatting functions
92
+ - `parseDate` - Date parsing
93
+ - `transformedData` - Processed chart data
94
+ - `currentViewport` - Responsive breakpoint
95
+
96
+ ## Testing
97
+
98
+ Run tests with:
99
+ ```bash
100
+ vitest packages/chart/src/components/LinearChart/tests/
101
+ ```
102
+
103
+ ## Refactoring History
104
+
105
+ This component was refactored from 1,704 lines to ~845 lines by extracting:
106
+ 1. Axis components to `../Axis/`
107
+ 2. VisualizationRenderer to handle chart type switching
108
+ 3. Tick formatting to `utils/tickFormatting.ts`
109
+ 4. Constants to `linearChart.constants.ts`