@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,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={
|
|
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`
|