@cdc/chart 4.25.11 → 4.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.local.md +79 -0
- package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
- package/dist/cdcchart.js +51401 -50814
- package/examples/default.json +378 -0
- package/examples/feature/__data__/horizon-chart-data.json +373 -0
- package/examples/feature/annotations/index.json +3 -6
- package/examples/feature/horizon/horizon-chart.json +395 -0
- package/examples/feature/pie/planet-pie-example-config.json +48 -2
- package/examples/line-chart-states.json +1085 -0
- package/examples/private/123.json +694 -0
- package/examples/private/DEV-12100.json +1303 -0
- package/examples/private/anchor-issue.json +4094 -0
- package/examples/private/backwards-slider.json +10430 -0
- package/examples/private/cat-y.json +1235 -0
- package/examples/private/data-points.json +228 -0
- package/examples/private/georgia.csv +160 -0
- package/examples/private/height.json +3915 -0
- package/examples/private/links.json +569 -0
- package/examples/private/quadrant.txt +30 -0
- package/examples/private/test-forecast.json +5510 -0
- package/examples/private/timeline-data.json +1 -0
- package/examples/private/timeline.json +389 -0
- package/examples/private/warming-stripe-test.json +2578 -0
- package/examples/private/warming-stripes.json +4763 -0
- package/examples/radar-chart-simple.json +133 -0
- package/examples/radar-chart.json +148 -0
- package/examples/tech-adoption-with-links.json +560 -0
- package/index.html +1 -36
- package/package.json +59 -60
- package/src/CdcChartComponent.tsx +206 -89
- package/src/_stories/Chart.Anchors.stories.tsx +10 -0
- package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
- package/src/_stories/Chart.CI.stories.tsx +13 -0
- package/src/_stories/Chart.Combo.stories.tsx +17 -0
- package/src/_stories/Chart.CustomColors.stories.tsx +4 -0
- package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
- package/src/_stories/Chart.Filters.stories.tsx +4 -0
- package/src/_stories/Chart.Forecast.stories.tsx +4 -0
- package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
- package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
- package/src/_stories/Chart.Patterns.stories.tsx +4 -0
- package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
- package/src/_stories/Chart.Regions.Categorical.stories.tsx +161 -0
- package/src/_stories/Chart.Regions.DateScale.stories.tsx +216 -0
- package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +312 -0
- package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
- package/src/_stories/Chart.stories.tsx +45 -0
- package/src/_stories/Chart.tooltip.stories.tsx +7 -0
- package/src/_stories/ChartAnnotation.stories.tsx +10 -0
- package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
- package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +11 -6
- package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
- package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
- package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
- package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
- package/src/_stories/ChartBrush.stories.tsx +57 -0
- package/src/_stories/ChartEditor.Editor.stories.tsx +3 -5
- package/src/_stories/ChartEditor.stories.tsx +7 -0
- package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
- package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
- package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
- package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
- package/src/_stories/TechAdoptionWithLinks.stories.tsx +34 -0
- package/src/_stories/_mock/brush_continuous.json +86 -0
- package/src/_stories/_mock/brush_date_large.json +176 -0
- package/src/_stories/_mock/brush_enabled.json +326 -0
- package/src/_stories/_mock/brush_mock.json +2 -69
- package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
- package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
- package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
- package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
- package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +11 -17
- package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
- package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
- package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
- package/src/components/Annotations/components/AnnotationList.styles.css +4 -10
- package/src/components/Annotations/components/AnnotationList.tsx +5 -4
- package/src/components/Annotations/components/findNearestDatum.ts +75 -85
- package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
- package/src/components/AreaChart/components/AreaChart.Stacked.jsx +1 -2
- package/src/components/Axis/BottomAxis.tsx +270 -0
- package/src/components/Axis/Categorical.Axis.tsx +6 -7
- package/src/components/Axis/LeftAxis.tsx +404 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
- package/src/components/Axis/PairedBarAxis.tsx +186 -0
- package/src/components/Axis/README.md +94 -0
- package/src/components/Axis/RightAxis.tsx +108 -0
- package/src/components/Axis/axis.constants.ts +21 -0
- package/src/components/Axis/index.ts +7 -0
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +178 -24
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
- package/src/components/BarChart/components/BarChart.Vertical.tsx +6 -8
- package/src/components/BarChart/components/BarChart.tsx +7 -1
- package/src/components/BarChart/components/context.tsx +1 -0
- package/src/components/BarChart/helpers/useBarChart.ts +14 -2
- package/src/components/Brush/BrushSelector.tsx +1390 -0
- package/src/components/Brush/MiniChartPreview.tsx +400 -0
- package/src/components/DeviationBar.jsx +9 -7
- package/src/components/EditorPanel/EditorPanel.tsx +2734 -2595
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +60 -22
- package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +137 -30
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +2 -0
- package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +0 -1
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +30 -25
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +42 -28
- package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +81 -39
- package/src/components/HorizonChart/HorizonChart.tsx +131 -0
- package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
- package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
- package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
- package/src/components/HorizonChart/index.tsx +3 -0
- package/src/components/Legend/Legend.Component.tsx +52 -4
- package/src/components/Legend/Legend.tsx +4 -3
- package/src/components/Legend/LegendValueRange.tsx +77 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +164 -2
- package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
- package/src/components/Legend/helpers/index.ts +10 -6
- package/src/components/LineChart/helpers/README.md +292 -0
- package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
- package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
- package/src/components/LineChart/index.tsx +44 -8
- package/src/components/LinearChart/README.md +109 -0
- package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
- package/src/components/LinearChart/linearChart.constants.ts +84 -0
- package/src/components/LinearChart/tests/LinearChart.test.tsx +201 -0
- package/src/components/LinearChart/tests/mockConfigContext.ts +129 -0
- package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
- package/src/components/LinearChart.tsx +338 -1082
- package/src/components/PairedBarChart.jsx +20 -3
- package/src/components/PieChart/PieChart.tsx +1 -1
- package/src/components/RadarChart/RadarAxis.tsx +78 -0
- package/src/components/RadarChart/RadarChart.tsx +298 -0
- package/src/components/RadarChart/RadarGrid.tsx +64 -0
- package/src/components/RadarChart/RadarPolygon.tsx +91 -0
- package/src/components/RadarChart/helpers.ts +83 -0
- package/src/components/RadarChart/index.tsx +3 -0
- package/src/components/Regions/components/Regions.tsx +365 -122
- package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +5 -1
- package/src/components/WarmingStripes/WarmingStripes.tsx +230 -0
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
- package/src/components/WarmingStripes/index.tsx +3 -0
- package/src/data/initial-state.js +17 -2
- package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
- package/src/helpers/getExcludedData.ts +4 -0
- package/src/helpers/getMinMax.ts +12 -7
- package/src/helpers/handleChartAriaLabels.ts +19 -19
- package/src/helpers/handleLineType.ts +22 -18
- package/src/helpers/sizeHelpers.ts +0 -20
- package/src/helpers/smallMultiplesHelpers.ts +1 -1
- package/src/hooks/useChartHoverAnalytics.tsx +10 -9
- package/src/hooks/useProgrammaticTooltip.ts +23 -2
- package/src/hooks/useScales.ts +18 -1
- package/src/hooks/useTooltip.tsx +34 -10
- package/src/scss/DataTable.scss +0 -4
- package/src/scss/main.scss +22 -3
- package/src/selectors/README.md +68 -0
- package/src/store/chart.reducer.ts +2 -0
- package/src/test/CdcChart.test.jsx +1 -1
- package/src/types/ChartConfig.ts +21 -0
- package/src/types/ChartContext.ts +1 -0
- package/src/types/Horizon.ts +64 -0
- package/src/types/Label.ts +1 -0
- package/src/utils/analyticsTracking.ts +19 -0
- package/LICENSE +0 -201
- package/src/components/Annotations/components/helpers/index.tsx +0 -46
- package/src/components/Brush/BrushChart.tsx +0 -128
- package/src/components/Brush/BrushController.tsx +0 -71
- package/src/components/Brush/types.tsx +0 -8
- package/src/components/BrushChart.tsx +0 -223
|
@@ -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
|
+
})
|