@cdc/chart 4.26.1 → 4.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  4. package/dist/cdcchart.js +54742 -49796
  5. package/examples/data/data-with-metadata.json +10 -0
  6. package/examples/default.json +378 -0
  7. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  8. package/examples/feature/annotations/index.json +3 -6
  9. package/examples/feature/horizon/horizon-chart.json +395 -0
  10. package/examples/feature/pie/planet-pie-example-config.json +2 -1
  11. package/examples/line-chart-states.json +1085 -0
  12. package/examples/metadata-variables.json +58 -0
  13. package/examples/private/123.json +694 -0
  14. package/examples/private/anchor-issue.json +4094 -0
  15. package/examples/private/backwards-slider.json +10430 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/timeline-data.json +1 -0
  18. package/examples/private/timeline.json +389 -0
  19. package/examples/radar-chart-simple.json +133 -0
  20. package/examples/radar-chart.json +148 -0
  21. package/index.html +1 -31
  22. package/package.json +57 -59
  23. package/src/CdcChart.tsx +8 -4
  24. package/src/CdcChartComponent.tsx +398 -284
  25. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  26. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  27. package/src/_stories/Chart.CI.stories.tsx +13 -0
  28. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  29. package/src/_stories/Chart.CustomColors.stories.tsx +78 -0
  30. package/src/_stories/Chart.Defaults.stories.tsx +95 -0
  31. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  32. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  33. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  34. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  35. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  36. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  37. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  38. package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
  39. package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
  40. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
  41. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  42. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  43. package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
  44. package/src/_stories/Chart.stories.tsx +72 -1
  45. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  46. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  47. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  48. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  49. package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
  50. package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
  51. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  52. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  53. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  54. package/src/_stories/ChartBrush.stories.tsx +7 -0
  55. package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
  56. package/src/_stories/ChartEditor.stories.tsx +7 -0
  57. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  58. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  59. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  60. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  61. package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
  62. package/src/_stories/_mock/brush_continuous.json +86 -0
  63. package/src/_stories/_mock/brush_date_large.json +176 -0
  64. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  65. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  66. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  67. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  68. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  69. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  70. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  71. package/src/_stories/_mock/paired-bar-abbr.json +421 -0
  72. package/src/_stories/_mock/pie_custom_colors.json +268 -0
  73. package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
  74. package/src/components/Annotations/components/AnnotationDraggable.styles.css +14 -20
  75. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  76. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  77. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  78. package/src/components/Annotations/components/AnnotationList.styles.css +12 -18
  79. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  80. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  81. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  82. package/src/components/Axis/BottomAxis.tsx +277 -0
  83. package/src/components/Axis/LeftAxis.tsx +404 -0
  84. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  85. package/src/components/Axis/PairedBarAxis.tsx +192 -0
  86. package/src/components/Axis/README.md +94 -0
  87. package/src/components/Axis/RightAxis.tsx +108 -0
  88. package/src/components/Axis/axis.constants.ts +21 -0
  89. package/src/components/Axis/index.ts +7 -0
  90. package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
  91. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
  92. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
  93. package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
  94. package/src/components/BarChart/components/BarChart.tsx +7 -1
  95. package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
  96. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
  97. package/src/components/BarChart/helpers/useBarChart.ts +3 -0
  98. package/src/components/Brush/BrushSelector.tsx +155 -22
  99. package/src/components/Brush/MiniChartPreview.tsx +133 -21
  100. package/src/components/EditorPanel/EditorPanel.tsx +81 -54
  101. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +67 -29
  102. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
  103. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +120 -2
  104. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +25 -43
  105. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  106. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -3
  107. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +66 -43
  108. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  109. package/src/components/EditorPanel/editor-panel.scss +1 -1
  110. package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
  111. package/src/components/ForestPlot/ForestPlot.tsx +26 -22
  112. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  113. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  114. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  115. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  116. package/src/components/HorizonChart/index.tsx +3 -0
  117. package/src/components/Legend/Legend.Component.tsx +52 -4
  118. package/src/components/Legend/Legend.tsx +1 -1
  119. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
  120. package/src/components/Legend/LegendValueRange.tsx +77 -0
  121. package/src/components/Legend/helpers/createFormatLabels.tsx +16 -2
  122. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  123. package/src/components/LineChart/helpers/README.md +292 -0
  124. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  125. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  126. package/src/components/LineChart/index.tsx +44 -8
  127. package/src/components/LinearChart/README.md +109 -0
  128. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  129. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  130. package/src/components/LinearChart/tests/LinearChart.test.tsx +278 -0
  131. package/src/components/LinearChart/tests/mockConfigContext.ts +131 -0
  132. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  133. package/src/components/LinearChart.tsx +268 -1057
  134. package/src/components/PieChart/PieChart.tsx +20 -5
  135. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  136. package/src/components/RadarChart/RadarChart.tsx +298 -0
  137. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  138. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  139. package/src/components/RadarChart/helpers.ts +83 -0
  140. package/src/components/RadarChart/index.tsx +3 -0
  141. package/src/components/Regions/components/Regions.tsx +6 -6
  142. package/src/components/Sankey/components/Sankey.tsx +3 -3
  143. package/src/components/Sankey/sankey.scss +1 -1
  144. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  145. package/src/components/Sparkline/index.scss +4 -2
  146. package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
  147. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
  148. package/src/data/initial-state.js +37 -15
  149. package/src/data/legacy-defaults.ts +18 -0
  150. package/src/helpers/abbreviateNumber.ts +24 -17
  151. package/src/helpers/getChartPatternId.ts +17 -0
  152. package/src/helpers/getExcludedData.ts +4 -0
  153. package/src/helpers/getMinMax.ts +16 -2
  154. package/src/helpers/handleChartAriaLabels.ts +19 -19
  155. package/src/helpers/handleLineType.ts +22 -18
  156. package/src/helpers/seriesColumnSettings.ts +114 -0
  157. package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
  158. package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
  159. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  160. package/src/hooks/useRightAxis.ts +14 -0
  161. package/src/hooks/useScales.ts +99 -56
  162. package/src/hooks/useTooltip.tsx +23 -3
  163. package/src/scss/main.scss +157 -79
  164. package/src/selectors/README.md +68 -0
  165. package/src/store/chart.reducer.ts +2 -0
  166. package/src/test/CdcChart.test.jsx +2 -2
  167. package/src/types/ChartConfig.ts +22 -0
  168. package/src/types/ChartContext.ts +1 -0
  169. package/src/types/Horizon.ts +64 -0
  170. package/tests/fixtures/chart-config-with-metadata.json +29 -0
  171. package/tests/fixtures/data-with-metadata.json +10 -0
  172. package/preview.html +0 -1616
  173. package/src/components/Annotations/components/helpers/index.tsx +0 -46
@@ -1,103 +1,93 @@
1
- const findNearestDatum = ({ data, xScale, yScale, config, xMax, annotationSeriesKey }, xPosition) => {
2
- const { xAxis, visualizationType, orientation } = config
3
-
4
- const convertXValueToTimestamp = (xValue, minX, maxX, domain, xScale) => {
5
- let ticks = []
6
- if (config.xAxis.type === 'date-time') {
7
- minX = new Date(minX)
8
- maxX = new Date(maxX)
9
- domain = domain.map(d => new Date(d))
10
- ticks = xScale.ticks().map(d => new Date(d))
11
- }
12
-
13
- // Calculate the percentage position of xValue between minX and maxX
14
- const percentage = (xValue - minX) / (maxX - minX)
15
-
16
- // Calculate the index in the domain array corresponding to the percentage position
17
- const index = Math.round(percentage * (domain.length - 1))
1
+ /**
2
+ * Finds the nearest data point to a given pixel coordinate.
3
+ * Uses the visible/filtered data only.
4
+ *
5
+ * @param data - The filtered/visible data array (transformedData)
6
+ * @param xScale - The x scale (can be band, time, or linear)
7
+ * @param xAxisType - Type of x axis ('categorical', 'date', 'date-time', or 'continuous')
8
+ * @param xAxisDataKey - The key used for x values in data rows
9
+ * @param seriesKey - The key used for y values in data rows
10
+ * @param xPixel - The pixel x coordinate to find nearest datum for
11
+ * @param parseDate - The parseDate function from ConfigContext (for date/date-time axes)
12
+ * @returns Object with { x: dataX, y: dataY } or null if not found
13
+ */
14
+ const findNearestDatum = ({ data, xScale, xAxisType, xAxisDataKey, seriesKey, xPixel, parseDate }) => {
15
+ if (!data || data.length === 0) {
16
+ return null
17
+ }
18
18
 
19
- if (config.xAxis.type === 'date-time') {
20
- return ticks[index]
19
+ // Handle categorical and date (both use band scales)
20
+ if (xAxisType === 'categorical' || xAxisType === 'date') {
21
+ const domain = xScale.domain()
22
+ const bandwidth = xScale.bandwidth?.() || 0
23
+
24
+ // Find closest band center
25
+ const closestValue = domain
26
+ .map(value => ({
27
+ value,
28
+ distance: Math.abs(xPixel - (xScale(value) + bandwidth / 2))
29
+ }))
30
+ .sort((a, b) => a.distance - b.distance)[0]?.value
31
+
32
+ // For date axes, closestValue is a timestamp; for categorical, it's the raw value
33
+ const dataRow = data.find(d =>
34
+ xAxisType === 'date' && parseDate
35
+ ? parseDate(d[xAxisDataKey], false)?.getTime() === closestValue
36
+ : d[xAxisDataKey] === closestValue
37
+ )
38
+
39
+ if (!dataRow) return null
40
+
41
+ return {
42
+ x: dataRow[xAxisDataKey],
43
+ y: dataRow[seriesKey]
21
44
  }
22
-
23
- // Return the timestamp from the domain array at the calculated index
24
- return domain[index]
25
45
  }
26
46
 
27
- const getXValueFromCoordinate = x => {
28
- if (visualizationType === 'Pie') return
29
- if (orientation === 'horizontal') return
30
-
31
- if (config.xAxis.type === 'date-time') {
32
- // Calculate the percentage position of xValue between minX and maxX
33
- const invertedValue = new Date(xScale.invert(x))
34
- const ticks = config.data.map(d => new Date(d[config.xAxis.dataKey]).getTime())
35
- let minDistance = Infinity
36
- let closestDate = null
37
-
38
- ticks.forEach(timestamp => {
39
- const distance = Math.abs(invertedValue.getTime() - timestamp)
40
- if (distance < minDistance) {
41
- minDistance = distance
42
- closestDate = timestamp
47
+ // Handle date-time (time scale with continuous dates)
48
+ if (xAxisType === 'date-time') {
49
+ const targetTime = xScale.invert(xPixel).getTime()
50
+
51
+ // Find closest data point by timestamp distance
52
+ const closestRow = data
53
+ .map(row => {
54
+ const rawValue = row[xAxisDataKey]
55
+ const parsedDate = parseDate ? parseDate(rawValue, false) : new Date(rawValue)
56
+ const timestamp = parsedDate?.getTime()
57
+ return {
58
+ row,
59
+ rawValue,
60
+ timestamp,
61
+ distance: Math.abs(timestamp - targetTime)
43
62
  }
44
63
  })
64
+ .sort((a, b) => a.distance - b.distance)[0]
45
65
 
46
- return new Date(closestDate).getTime()
47
- }
66
+ if (!closestRow) return null
48
67
 
49
- // Check the type of x equal to point or if the type of xAxis is equal to continuous or date
50
- if (
51
- config.xAxis.type === 'categorical' ||
52
- (visualizationType === 'Combo' && orientation !== 'horizontal' && visualizationType !== 'Forest Plot')
53
- ) {
54
- const range = xScale.range()[1] - xScale.range()[0]
55
- const eachBand = range / (xScale.domain().length + 1)
56
-
57
- let numerator = x
58
- const index = Math.floor((Number(numerator) - eachBand / 2) / eachBand)
59
- return xScale.domain()[index] // fixes off by 1 error
68
+ return {
69
+ x: closestRow.rawValue,
70
+ y: closestRow.row[seriesKey]
60
71
  }
61
-
62
- if (config.xAxis.type === 'date') {
63
- const xValue = x // Assuming x is the coordinate on the chart
64
- const xTimestamp = convertXValueToTimestamp(x, 0, xMax, xScale.domain(), xScale)
65
-
66
- // Calculate the closest date to the x coordinate
67
- let closestDate = null
68
- let minDistance = Number.MAX_VALUE
69
-
70
- xScale.domain().forEach(timestamp => {
71
- const distance = Math.abs(xTimestamp - timestamp)
72
- if (distance < minDistance) {
73
- minDistance = distance
74
- closestDate = timestamp
75
- }
76
- })
77
-
78
- return closestDate
79
- }
80
-
81
- return x
82
72
  }
83
73
 
84
- const xValue = getXValueFromCoordinate(xPosition - Number(config.yAxis.size || 0))
74
+ // Handle continuous (linear scale)
75
+ const invertedValue = xScale.invert(xPixel)
85
76
 
86
- let closestSeries = []
77
+ // Find closest data point by numeric distance
78
+ const closestRow = data
79
+ .map(row => ({
80
+ row,
81
+ distance: Math.abs(Number(row[xAxisDataKey]) - invertedValue)
82
+ }))
83
+ .sort((a, b) => a.distance - b.distance)[0]?.row
87
84
 
88
- if (!xValue) return { x: 0, y: 0 }
85
+ if (!closestRow) return null
89
86
 
90
- if (xAxis.type === 'categorical') {
91
- closestSeries = config.data.filter(d => d[config.xAxis.dataKey] === xValue)
87
+ return {
88
+ x: closestRow[xAxisDataKey],
89
+ y: closestRow[seriesKey]
92
90
  }
93
-
94
- if (xAxis.type === 'date' || xAxis.type === 'date-time') {
95
- closestSeries = config.data.filter(d => new Date(d[config.xAxis.dataKey]).getTime() === xValue)
96
- }
97
-
98
- const y = closestSeries[0][annotationSeriesKey] // Map each key to its corresponding value in data
99
- const x = xValue
100
- return { x, y }
101
91
  }
102
92
 
103
93
  export { findNearestDatum }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Filters annotations to only include those that should be visible based on current data.
3
+ *
4
+ * - Fixed-mode annotations are always visible
5
+ * - Data-mode annotations are only visible if their associated data point
6
+ * exists in the current filtered/transformed data
7
+ *
8
+ * @param annotations - Array of annotation objects
9
+ * @param transformedData - The currently visible/filtered data
10
+ * @param xAxisDataKey - The key used to identify data points on the x-axis
11
+ * @returns Array of annotations that should be visible
12
+ */
13
+ const getVisibleAnnotations = (
14
+ annotations: any[] | undefined,
15
+ transformedData: any[] | undefined,
16
+ xAxisDataKey: string
17
+ ): any[] => {
18
+ if (!annotations || !Array.isArray(annotations)) {
19
+ return []
20
+ }
21
+
22
+ return annotations.filter(annotation => {
23
+ // Fixed-mode annotations are always visible
24
+ if (annotation.anchorMode !== 'data') {
25
+ return true
26
+ }
27
+
28
+ // Data-mode: check if the data point exists in current data
29
+ if (annotation.dataX === undefined) {
30
+ return true // No dataX specified, show it
31
+ }
32
+
33
+ const dataSource = transformedData || []
34
+ return dataSource.some(d => d[xAxisDataKey] === annotation.dataX)
35
+ })
36
+ }
37
+
38
+ export { getVisibleAnnotations }
@@ -0,0 +1,277 @@
1
+ import React, { useContext } from 'react'
2
+ import { AxisBottom as VisxAxisBottom } from '@visx/axis'
3
+ import { Group } from '@visx/group'
4
+ import { Line } from '@visx/shape'
5
+ import { Text } from '@visx/text'
6
+ import ConfigContext from '../../ConfigContext'
7
+ import { isDateScale } from '@cdc/core/helpers/cove/date'
8
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
9
+ import { filterAndShiftLinearDateTicks } from '../../helpers/filterAndShiftLinearDateTicks'
10
+ import { getTickValues } from '../../hooks/useScales'
11
+
12
+ // Constants for bottom axis
13
+ const BOTTOM_LABEL_PADDING = 9
14
+ const X_TICK_LABEL_PADDING = 4.5
15
+ const DEFAULT_TICK_LENGTH = 8
16
+ const TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD = -50
17
+ const TICK_BUFFER_SPACING = 40
18
+ const MAJOR_TICK_LENGTH = 16
19
+ const MAJOR_LOG_TICK_STROKE_WIDTH = 1.3
20
+ const HORIZONTAL_LOG_DY_OFFSET = 8
21
+ const DYNAMIC_MARGIN_TOP_PADDING = 20
22
+ const BASE_TICK_WIDTH_ACCUMULATOR = 100
23
+ const MULTI_LABEL_ACCUMULATOR = 180
24
+
25
+ interface BottomAxisProps {
26
+ axisBottomRef: React.RefObject<SVGGElement>
27
+ xScale: any
28
+ yMax: number
29
+ xMax: number
30
+ yAxisWidth: number
31
+ xTickCount: number
32
+ tickLabelFontSize: number
33
+ axisLabelFontSize: number
34
+ handleBottomTickFormatting: (tick: any, index: number, ticks: any[]) => string
35
+ useDateSpanMonths: boolean
36
+ dateSpanMonths: number
37
+ xAxisDataMapped: any[]
38
+ uniqueXAxisDataMapped: any[]
39
+ isDateTime: boolean
40
+ bottomLabelStart: number
41
+ parentWidth: number
42
+ xAxisLabelRefs: React.MutableRefObject<any[]>
43
+ xAxisTitleRef: React.RefObject<SVGTextElement>
44
+ getManualStep: () => number
45
+ }
46
+
47
+ const BottomAxis: React.FC<BottomAxisProps> = ({
48
+ axisBottomRef,
49
+ xScale,
50
+ yMax,
51
+ xMax,
52
+ yAxisWidth,
53
+ xTickCount,
54
+ tickLabelFontSize,
55
+ axisLabelFontSize,
56
+ handleBottomTickFormatting,
57
+ useDateSpanMonths,
58
+ dateSpanMonths,
59
+ xAxisDataMapped,
60
+ uniqueXAxisDataMapped,
61
+ isDateTime,
62
+ bottomLabelStart,
63
+ parentWidth,
64
+ xAxisLabelRefs,
65
+ xAxisTitleRef,
66
+ getManualStep
67
+ }) => {
68
+ const { config, formatDate } = useContext(ConfigContext)
69
+ const { runtime, orientation, visualizationType, heights } = config
70
+
71
+ const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
72
+ const isForestPlot = visualizationType === 'Forest Plot'
73
+
74
+ // Sort uniqueXAxisDataMapped to match the scale domain order for filterAndShiftLinearDateTicks
75
+ const sortedUniqueXAxisData = config.xAxis.sortByRecentDate
76
+ ? [...uniqueXAxisDataMapped].sort((a, b) => Number(b) - Number(a))
77
+ : [...uniqueXAxisDataMapped].sort((a, b) => Number(a) - Number(b))
78
+ const GET_TEXT_WIDTH_FONT = `normal ${tickLabelFontSize}px Nunito, sans-serif`
79
+
80
+ return (
81
+ <VisxAxisBottom
82
+ innerRef={axisBottomRef}
83
+ top={
84
+ runtime.horizontal && visualizationType !== 'Forest Plot'
85
+ ? Number(heights.horizontal) + Number(config.xAxis.axisPadding)
86
+ : visualizationType === 'Forest Plot'
87
+ ? yMax + Number(config.xAxis.axisPadding)
88
+ : yMax
89
+ }
90
+ left={visualizationType !== 'Forest Plot' ? yAxisWidth : 0}
91
+ label={runtime.xAxis.label}
92
+ tickFormat={handleBottomTickFormatting}
93
+ scale={xScale}
94
+ stroke='#333'
95
+ numTicks={useDateSpanMonths ? dateSpanMonths : xTickCount}
96
+ tickStroke='#333'
97
+ tickValues={
98
+ config.runtime.xAxis.manual
99
+ ? getTickValues(xAxisDataMapped, xScale, isDateTime ? xTickCount : getManualStep(), config)
100
+ : config.runtime.xAxis.type === 'date'
101
+ ? xAxisDataMapped
102
+ : // For date-time with small datasets (e.g., brush-filtered), use explicit tick values
103
+ // to ensure each data point can have a tick. Otherwise, visx may generate too few.
104
+ // Use uniqueXAxisDataMapped to handle cases where multiple series share x-axis values
105
+ isDateTime && uniqueXAxisDataMapped.length > 0 && uniqueXAxisDataMapped.length <= (xTickCount || 15)
106
+ ? uniqueXAxisDataMapped
107
+ : undefined
108
+ }
109
+ >
110
+ {props => {
111
+ const hasDynamicCategory = config.series.some(s => s.dynamicCategory)
112
+
113
+ // For these charts, we generated all ticks in tickValues above, and now need to filter/shift them
114
+ // so the last tick is always labeled
115
+ // Use sortedUniqueXAxisData to match the scale's sorted domain for correct index calculations
116
+ if (config.runtime.xAxis.type === 'date' && !config.runtime.xAxis.manual && !hasDynamicCategory) {
117
+ props.ticks = filterAndShiftLinearDateTicks(config, props, sortedUniqueXAxisData, formatDate)
118
+ }
119
+
120
+ const distanceBetweenTicks =
121
+ useDateSpanMonths &&
122
+ xScale
123
+ .ticks(xTickCount)
124
+ .map(t =>
125
+ props.ticks.findIndex(
126
+ tick => (typeof tick.value === 'number' ? tick.value : tick.value.getTime()) === t.getTime()
127
+ )
128
+ )
129
+ .slice(0, 2)
130
+ .reduce((acc, curr) => curr - acc)
131
+
132
+ // filter out every [distanceBetweenTicks] tick starting from the end, so the last tick is always labeled
133
+ const filteredTicks = useDateSpanMonths
134
+ ? [...props.ticks]
135
+ .reverse()
136
+ .filter((_, i) => i % distanceBetweenTicks === 0)
137
+ .reverse()
138
+ .map((tick, i, arr) => ({
139
+ ...tick,
140
+ // reformat in case showYearsOnce, since first month of year may have changed
141
+ formattedValue: handleBottomTickFormatting(tick.value, i, arr)
142
+ }))
143
+ : props.ticks
144
+
145
+ const axisMaxHeight = bottomLabelStart + BOTTOM_LABEL_PADDING
146
+
147
+ const containsMultipleWords = inputString => /\s/.test(inputString)
148
+ const isMultiLabel = filteredTicks.some(tick => containsMultipleWords(tick.value))
149
+
150
+ // Calculate sumOfTickWidth here, before map function
151
+ const longestTickLength = Math.max(
152
+ ...filteredTicks.map(tick => getTextWidth(tick.formattedValue, GET_TEXT_WIDTH_FONT))
153
+ )
154
+ const accumulator = isMultiLabel ? MULTI_LABEL_ACCUMULATOR : BASE_TICK_WIDTH_ACCUMULATOR
155
+
156
+ const textWidths = filteredTicks.map(tick => getTextWidth(tick.formattedValue, GET_TEXT_WIDTH_FONT))
157
+ const sumOfTickWidth = textWidths.reduce((a, b) => a + b, accumulator)
158
+ const spaceBetweenEachTick = (xMax - sumOfTickWidth) / (filteredTicks.length - 1)
159
+ const bufferBetweenTicks = TICK_BUFFER_SPACING
160
+ const maxLengthOfTick = parentWidth / filteredTicks.length - X_TICK_LABEL_PADDING * 2 - bufferBetweenTicks
161
+
162
+ // Determine the position of each tick
163
+ let positions = [0] // The first tick is at position 0
164
+ for (let i = 1; i < textWidths.length; i++) {
165
+ // The position of each subsequent tick is the position of the previous tick
166
+ // plus the width of the previous tick and the space
167
+ positions[i] = positions[i - 1] + textWidths[i - 1] + spaceBetweenEachTick
168
+ }
169
+ // calculate the end of x axis box
170
+ const axisBBox = axisBottomRef?.current?.getBBox().height
171
+ // TODO: Technical debt - This mutation is read by parent/sibling components.
172
+ // Should be refactored to use proper React state management or callbacks.
173
+ config.xAxis.axisBBox = axisBBox
174
+
175
+ // force wrap it last tick is close to the end of the axis
176
+ const lastTickWidth = textWidths[textWidths.length - 1]
177
+ const lastTickPosition = positions[positions.length - 1] + lastTickWidth
178
+ const lastTickEnd = lastTickPosition + lastTickWidth / 2
179
+ const lastTickEndThreshold = xMax - lastTickWidth
180
+
181
+ const areTicksTouching =
182
+ textWidths.some(textWidth => textWidth > maxLengthOfTick) || // Force wrap if any tick is too long
183
+ config.xAxis.showYearsOnce || // Force wrap when showing years once so it's easier to read
184
+ lastTickEnd > lastTickEndThreshold // Force wrap it last tick is close to the end of the axis
185
+
186
+ const dynamicMarginTop =
187
+ areTicksTouching && config.isResponsiveTicks
188
+ ? longestTickLength + DEFAULT_TICK_LENGTH + DYNAMIC_MARGIN_TOP_PADDING
189
+ : 0
190
+
191
+ // TODO: Technical debt - These mutations are read by parent/sibling components.
192
+ // Should be refactored to use proper React state management or callbacks.
193
+ config.dynamicMarginTop = dynamicMarginTop
194
+ config.xAxis.tickWidthMax = longestTickLength
195
+
196
+ // Compute effective tick rotations without mutating config
197
+ const effectiveXAxisTickRotation =
198
+ config.isResponsiveTicks && config.orientation === 'vertical' ? 0 : config.xAxis.tickRotation
199
+
200
+ return (
201
+ <Group className='bottom-axis' width={parentWidth}>
202
+ {filteredTicks.map((tick, i) => {
203
+ // when using LogScale show major ticks values only
204
+ const showTick = String(tick.value).startsWith('1') || tick.value === 0.1 ? 'block' : 'none'
205
+ const tickLength = showTick === 'block' ? MAJOR_TICK_LENGTH : DEFAULT_TICK_LENGTH
206
+ const to = { x: tick.to.x, y: tickLength }
207
+ const tickSlotWidth = filteredTicks.length > 0 ? xMax / filteredTicks.length : xMax
208
+ const limitedWidth = Math.max(Math.min(maxLengthOfTick, tickSlotWidth), 0)
209
+
210
+ // Configure rotation using effective values (computed above without mutations)
211
+ const tickRotation =
212
+ config.isResponsiveTicks && areTicksTouching
213
+ ? -Number(config.xAxis.maxTickRotation) || -90
214
+ : -Number(config.runtime.xAxis.tickRotation)
215
+
216
+ return (
217
+ <Group key={`vx-tick-${tick.value}-${i}`} className={'vx-axis-tick'}>
218
+ {!config.xAxis.hideTicks && (
219
+ <Line
220
+ from={tick.from}
221
+ to={orientation === 'horizontal' && isLogarithmicAxis ? to : tick.to}
222
+ stroke={config.xAxis.tickColor}
223
+ strokeWidth={showTick === 'block' && isLogarithmicAxis ? MAJOR_LOG_TICK_STROKE_WIDTH : 1}
224
+ />
225
+ )}
226
+ {!config.xAxis.hideLabel && (
227
+ <Text
228
+ innerRef={el => (xAxisLabelRefs.current[i] = el)}
229
+ dy={config.orientation === 'horizontal' && isLogarithmicAxis ? HORIZONTAL_LOG_DY_OFFSET : 0}
230
+ display={config.orientation === 'horizontal' && isLogarithmicAxis ? showTick : 'block'}
231
+ x={tick.to.x}
232
+ y={tick.to.y + X_TICK_LABEL_PADDING}
233
+ angle={tickRotation}
234
+ verticalAnchor={tickRotation < TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD ? 'middle' : 'start'}
235
+ textAnchor={tickRotation ? 'end' : 'middle'}
236
+ width={
237
+ areTicksTouching && !config.isResponsiveTicks && !Number(effectiveXAxisTickRotation)
238
+ ? limitedWidth
239
+ : undefined
240
+ }
241
+ fill={config.xAxis.tickLabelColor}
242
+ fontSize={tickLabelFontSize}
243
+ >
244
+ {tick.formattedValue}
245
+ </Text>
246
+ )}
247
+ </Group>
248
+ )
249
+ })}
250
+ {!config.xAxis.hideAxis && (
251
+ <Line
252
+ from={isForestPlot ? { ...props.axisFromPoint, x: 0 } : props.axisFromPoint}
253
+ to={props.axisToPoint}
254
+ stroke='#333'
255
+ />
256
+ )}
257
+ <Text
258
+ innerRef={xAxisTitleRef}
259
+ className='x-axis-title-label'
260
+ x={xMax / 2}
261
+ y={isForestPlot ? 0 /* set via ref */ : axisMaxHeight}
262
+ textAnchor='middle'
263
+ verticalAnchor='start'
264
+ fontWeight='bold'
265
+ fill={config.xAxis.labelColor}
266
+ fontSize={axisLabelFontSize}
267
+ >
268
+ {!config.hideXAxisLabel ? props.label : null}
269
+ </Text>
270
+ </Group>
271
+ )
272
+ }}
273
+ </VisxAxisBottom>
274
+ )
275
+ }
276
+
277
+ export default BottomAxis