@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
|
@@ -1,103 +1,93 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
}
|
|
66
|
+
if (!closestRow) return null
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
74
|
+
// Handle continuous (linear scale)
|
|
75
|
+
const invertedValue = xScale.invert(xPixel)
|
|
85
76
|
|
|
86
|
-
|
|
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 (!
|
|
85
|
+
if (!closestRow) return null
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
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 }
|
|
@@ -13,8 +13,7 @@ import { approvedCurveTypes } from '@cdc/core/helpers/lineChartHelpers'
|
|
|
13
13
|
|
|
14
14
|
const AreaChartStacked = ({ xScale, yScale, yMax, xMax, handleTooltipMouseOver, handleTooltipMouseOff }) => {
|
|
15
15
|
// import data from context
|
|
16
|
-
let { transformedData, config, seriesHighlight, colorScale, rawData, parseDate } = useContext(ConfigContext)
|
|
17
|
-
const data = config.brush?.active && config.brush.data?.length ? config.brush.data : transformedData
|
|
16
|
+
let { transformedData: data, config, seriesHighlight, colorScale, rawData, parseDate } = useContext(ConfigContext)
|
|
18
17
|
// Draw transparent bars over the chart to get tooltip data
|
|
19
18
|
// Turn DEBUG on for additional context.
|
|
20
19
|
if (!data) return
|
|
@@ -0,0 +1,270 @@
|
|
|
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, propsTicks) => {
|
|
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 limitedWidth = 100 / propsTicks.length
|
|
208
|
+
|
|
209
|
+
// Configure rotation using effective values (computed above without mutations)
|
|
210
|
+
const tickRotation =
|
|
211
|
+
config.isResponsiveTicks && areTicksTouching
|
|
212
|
+
? -Number(config.xAxis.maxTickRotation) || -90
|
|
213
|
+
: -Number(config.runtime.xAxis.tickRotation)
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Group key={`vx-tick-${tick.value}-${i}`} className={'vx-axis-tick'}>
|
|
217
|
+
{!config.xAxis.hideTicks && (
|
|
218
|
+
<Line
|
|
219
|
+
from={tick.from}
|
|
220
|
+
to={orientation === 'horizontal' && isLogarithmicAxis ? to : tick.to}
|
|
221
|
+
stroke={config.xAxis.tickColor}
|
|
222
|
+
strokeWidth={showTick === 'block' && isLogarithmicAxis ? MAJOR_LOG_TICK_STROKE_WIDTH : 1}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
{!config.xAxis.hideLabel && (
|
|
226
|
+
<Text
|
|
227
|
+
innerRef={el => (xAxisLabelRefs.current[i] = el)}
|
|
228
|
+
dy={config.orientation === 'horizontal' && isLogarithmicAxis ? HORIZONTAL_LOG_DY_OFFSET : 0}
|
|
229
|
+
display={config.orientation === 'horizontal' && isLogarithmicAxis ? showTick : 'block'}
|
|
230
|
+
x={tick.to.x}
|
|
231
|
+
y={tick.to.y + X_TICK_LABEL_PADDING}
|
|
232
|
+
angle={tickRotation}
|
|
233
|
+
verticalAnchor={tickRotation < TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD ? 'middle' : 'start'}
|
|
234
|
+
textAnchor={tickRotation ? 'end' : 'middle'}
|
|
235
|
+
width={
|
|
236
|
+
areTicksTouching && !config.isResponsiveTicks && !Number(effectiveXAxisTickRotation)
|
|
237
|
+
? limitedWidth
|
|
238
|
+
: undefined
|
|
239
|
+
}
|
|
240
|
+
fill={config.xAxis.tickLabelColor}
|
|
241
|
+
fontSize={tickLabelFontSize}
|
|
242
|
+
>
|
|
243
|
+
{tick.formattedValue}
|
|
244
|
+
</Text>
|
|
245
|
+
)}
|
|
246
|
+
</Group>
|
|
247
|
+
)
|
|
248
|
+
})}
|
|
249
|
+
{!config.xAxis.hideAxis && <Line from={props.axisFromPoint} to={props.axisToPoint} stroke='#333' />}
|
|
250
|
+
<Text
|
|
251
|
+
innerRef={xAxisTitleRef}
|
|
252
|
+
className='x-axis-title-label'
|
|
253
|
+
x={xMax / 2}
|
|
254
|
+
y={isForestPlot ? 0 /* set via ref */ : axisMaxHeight}
|
|
255
|
+
textAnchor='middle'
|
|
256
|
+
verticalAnchor='start'
|
|
257
|
+
fontWeight='bold'
|
|
258
|
+
fill={config.xAxis.labelColor}
|
|
259
|
+
fontSize={axisLabelFontSize}
|
|
260
|
+
>
|
|
261
|
+
{!config.hideXAxisLabel ? props.label : null}
|
|
262
|
+
</Text>
|
|
263
|
+
</Group>
|
|
264
|
+
)
|
|
265
|
+
}}
|
|
266
|
+
</VisxAxisBottom>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export default BottomAxis
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useContext } from 'react'
|
|
2
2
|
import { BarStack, Line } from '@visx/shape'
|
|
3
|
-
import { scaleBand,
|
|
3
|
+
import { scaleBand, scaleOrdinal } from '@visx/scale'
|
|
4
4
|
import { Group } from '@visx/group'
|
|
5
5
|
import { Text } from '@visx/text'
|
|
6
6
|
import ConfigContext from '../../ConfigContext'
|
|
@@ -9,7 +9,7 @@ import createBarElement from '@cdc/core/components/createBarElement'
|
|
|
9
9
|
import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
|
|
10
10
|
import { APP_FONT_SIZE } from '@cdc/core/helpers/constants'
|
|
11
11
|
|
|
12
|
-
const CategoricalYAxis = ({ yMax, leftSize,
|
|
12
|
+
const CategoricalYAxis = ({ yScale, yMax, leftSize, xMax }) => {
|
|
13
13
|
const { config } = useContext(ConfigContext)
|
|
14
14
|
|
|
15
15
|
const { orientation } = config
|
|
@@ -24,6 +24,9 @@ const CategoricalYAxis = ({ yMax, leftSize, max, xMax }) => {
|
|
|
24
24
|
|
|
25
25
|
const categories = config.yAxis?.categories
|
|
26
26
|
|
|
27
|
+
// Get max from the yScale domain
|
|
28
|
+
const max = yScale.domain()[1]
|
|
29
|
+
|
|
27
30
|
const createDataShape = categories => {
|
|
28
31
|
const categoryObj = [...categories].reduce((acc, item) => {
|
|
29
32
|
acc[item.label] = item.height
|
|
@@ -68,11 +71,7 @@ const CategoricalYAxis = ({ yMax, leftSize, max, xMax }) => {
|
|
|
68
71
|
range: [0, leftSize]
|
|
69
72
|
})
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
domain: [0, max],
|
|
73
|
-
range: [yMax, 0],
|
|
74
|
-
clamp: true
|
|
75
|
-
})
|
|
74
|
+
// Use the yScale passed from useScales instead of creating a new one
|
|
76
75
|
|
|
77
76
|
const colorScale = scaleOrdinal({
|
|
78
77
|
domain: categories.map(d => d?.label),
|