@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,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
@@ -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
+ })