@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.
- package/CLAUDE.local.md +79 -0
- package/LICENSE +201 -0
- package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
- package/dist/cdcchart.js +54742 -49796
- package/examples/data/data-with-metadata.json +10 -0
- 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 +2 -1
- package/examples/line-chart-states.json +1085 -0
- package/examples/metadata-variables.json +58 -0
- package/examples/private/123.json +694 -0
- package/examples/private/anchor-issue.json +4094 -0
- package/examples/private/backwards-slider.json +10430 -0
- package/examples/private/georgia.csv +160 -0
- package/examples/private/timeline-data.json +1 -0
- package/examples/private/timeline.json +389 -0
- package/examples/radar-chart-simple.json +133 -0
- package/examples/radar-chart.json +148 -0
- package/index.html +1 -31
- package/package.json +57 -59
- package/src/CdcChart.tsx +8 -4
- package/src/CdcChartComponent.tsx +398 -284
- 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 +78 -0
- package/src/_stories/Chart.Defaults.stories.tsx +95 -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 +13 -0
- package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
- package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
- package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
- package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
- package/src/_stories/Chart.stories.tsx +72 -1
- 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 +97 -38
- package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
- 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 +7 -0
- package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
- 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 +7 -0
- package/src/_stories/_mock/brush_continuous.json +86 -0
- package/src/_stories/_mock/brush_date_large.json +176 -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/_stories/_mock/paired-bar-abbr.json +421 -0
- package/src/_stories/_mock/pie_custom_colors.json +268 -0
- package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +14 -20
- 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 +12 -18
- 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/Axis/BottomAxis.tsx +277 -0
- package/src/components/Axis/LeftAxis.tsx +404 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
- package/src/components/Axis/PairedBarAxis.tsx +192 -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 +12 -28
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
- package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
- package/src/components/BarChart/components/BarChart.tsx +7 -1
- package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
- package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
- package/src/components/BarChart/helpers/useBarChart.ts +3 -0
- package/src/components/Brush/BrushSelector.tsx +155 -22
- package/src/components/Brush/MiniChartPreview.tsx +133 -21
- package/src/components/EditorPanel/EditorPanel.tsx +81 -54
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +67 -29
- package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +120 -2
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +25 -43
- package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -3
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +66 -43
- package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
- package/src/components/EditorPanel/editor-panel.scss +1 -1
- package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
- package/src/components/ForestPlot/ForestPlot.tsx +26 -22
- 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 +1 -1
- package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
- package/src/components/Legend/LegendValueRange.tsx +77 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +16 -2
- package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
- 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 +278 -0
- package/src/components/LinearChart/tests/mockConfigContext.ts +131 -0
- package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
- package/src/components/LinearChart.tsx +268 -1057
- package/src/components/PieChart/PieChart.tsx +20 -5
- 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 +6 -6
- package/src/components/Sankey/components/Sankey.tsx +3 -3
- package/src/components/Sankey/sankey.scss +1 -1
- package/src/components/SmallMultiples/SmallMultiples.css +5 -5
- package/src/components/Sparkline/index.scss +4 -2
- package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
- package/src/data/initial-state.js +37 -15
- package/src/data/legacy-defaults.ts +18 -0
- package/src/helpers/abbreviateNumber.ts +24 -17
- package/src/helpers/getChartPatternId.ts +17 -0
- package/src/helpers/getExcludedData.ts +4 -0
- package/src/helpers/getMinMax.ts +16 -2
- package/src/helpers/handleChartAriaLabels.ts +19 -19
- package/src/helpers/handleLineType.ts +22 -18
- package/src/helpers/seriesColumnSettings.ts +114 -0
- package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
- package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
- package/src/hooks/useProgrammaticTooltip.ts +23 -2
- package/src/hooks/useRightAxis.ts +14 -0
- package/src/hooks/useScales.ts +99 -56
- package/src/hooks/useTooltip.tsx +23 -3
- package/src/scss/main.scss +157 -79
- package/src/selectors/README.md +68 -0
- package/src/store/chart.reducer.ts +2 -0
- package/src/test/CdcChart.test.jsx +2 -2
- package/src/types/ChartConfig.ts +22 -0
- package/src/types/ChartContext.ts +1 -0
- package/src/types/Horizon.ts +64 -0
- package/tests/fixtures/chart-config-with-metadata.json +29 -0
- package/tests/fixtures/data-with-metadata.json +10 -0
- package/preview.html +0 -1616
- package/src/components/Annotations/components/helpers/index.tsx +0 -46
|
@@ -16,9 +16,11 @@ export const useEditorPermissions = () => {
|
|
|
16
16
|
'Deviation Bar',
|
|
17
17
|
'Forecasting',
|
|
18
18
|
// 'Forest Plot',
|
|
19
|
+
'Horizon Chart',
|
|
19
20
|
'Line',
|
|
20
21
|
'Paired Bar',
|
|
21
22
|
'Pie',
|
|
23
|
+
'Radar',
|
|
22
24
|
'Scatter Plot',
|
|
23
25
|
'Spark Line',
|
|
24
26
|
'Sankey',
|
|
@@ -26,7 +28,7 @@ export const useEditorPermissions = () => {
|
|
|
26
28
|
]
|
|
27
29
|
|
|
28
30
|
const visSupportsDateCategoryAxis = () => {
|
|
29
|
-
const disabledCharts = ['Forest Plot', 'Sankey']
|
|
31
|
+
const disabledCharts = ['Forest Plot', 'Radar', 'Sankey']
|
|
30
32
|
if (disabledCharts.includes(visualizationType)) return false
|
|
31
33
|
return true
|
|
32
34
|
}
|
|
@@ -55,16 +57,30 @@ export const useEditorPermissions = () => {
|
|
|
55
57
|
return true
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
const visSupportsClickingLegend = () => {
|
|
61
|
+
const disabledCharts = ['Horizon Chart']
|
|
62
|
+
if (disabledCharts.includes(visualizationType)) return false
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const visSupportsDataAnnotations = () => {
|
|
67
|
+
const enabledCharts = ['Line', 'Bar', 'Combo', 'Area Chart', 'Forecasting']
|
|
68
|
+
if (enabledCharts.includes(visualizationType) && config.orientation !== 'horizontal') return true
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
const visHasLabelOnData = () => {
|
|
59
73
|
const disabledCharts = [
|
|
60
74
|
'Area Chart',
|
|
61
75
|
'Box Plot',
|
|
76
|
+
'Bump Chart',
|
|
77
|
+
'Forest Plot',
|
|
78
|
+
'Horizon Chart',
|
|
62
79
|
'Pie',
|
|
80
|
+
'Radar',
|
|
81
|
+
'Sankey',
|
|
63
82
|
'Scatter Plot',
|
|
64
|
-
'Forest Plot',
|
|
65
83
|
'Spark Line',
|
|
66
|
-
'Sankey',
|
|
67
|
-
'Bump Chart',
|
|
68
84
|
'Warming Stripes'
|
|
69
85
|
]
|
|
70
86
|
if (disabledCharts.includes(visualizationType)) return false
|
|
@@ -74,12 +90,13 @@ export const useEditorPermissions = () => {
|
|
|
74
90
|
const visCanAnimate = () => {
|
|
75
91
|
const disabledCharts = [
|
|
76
92
|
'Area Chart',
|
|
77
|
-
'Scatter Plot',
|
|
78
93
|
'Box Plot',
|
|
94
|
+
'Bump Chart',
|
|
79
95
|
'Forest Plot',
|
|
80
|
-
'
|
|
96
|
+
'Radar',
|
|
81
97
|
'Sankey',
|
|
82
|
-
'
|
|
98
|
+
'Scatter Plot',
|
|
99
|
+
'Spark Line',
|
|
83
100
|
'Warming Stripes'
|
|
84
101
|
]
|
|
85
102
|
if (disabledCharts.includes(visualizationType)) return false
|
|
@@ -145,10 +162,11 @@ export const useEditorPermissions = () => {
|
|
|
145
162
|
}
|
|
146
163
|
const visHasBrushChart = () => {
|
|
147
164
|
if (config.xAxis.type === 'categorical') return false
|
|
148
|
-
// Allow Line charts, vertical Bar charts (both stacked and grouped),
|
|
165
|
+
// Allow Line charts, vertical Bar charts (both stacked and grouped), vertical Area charts, and Combo charts
|
|
149
166
|
if (visualizationType === 'Line' && orientation === 'vertical') return true
|
|
150
167
|
if (visualizationType === 'Bar' && orientation === 'vertical') return true
|
|
151
168
|
if (visualizationType === 'Area Chart' && orientation === 'vertical') return true
|
|
169
|
+
if (visualizationType === 'Combo' && orientation === 'vertical') return true
|
|
152
170
|
return false
|
|
153
171
|
}
|
|
154
172
|
|
|
@@ -256,7 +274,16 @@ export const useEditorPermissions = () => {
|
|
|
256
274
|
}
|
|
257
275
|
|
|
258
276
|
const visSupportsRegions = () => {
|
|
259
|
-
const disabledCharts = [
|
|
277
|
+
const disabledCharts = [
|
|
278
|
+
'Forest Plot',
|
|
279
|
+
'Horizon Chart',
|
|
280
|
+
'Pie',
|
|
281
|
+
'Paired Bar',
|
|
282
|
+
'Radar',
|
|
283
|
+
'Spark Line',
|
|
284
|
+
'Sankey',
|
|
285
|
+
'Warming Stripes'
|
|
286
|
+
]
|
|
260
287
|
if (disabledCharts.includes(visualizationType)) return false
|
|
261
288
|
return true
|
|
262
289
|
}
|
|
@@ -326,13 +353,13 @@ export const useEditorPermissions = () => {
|
|
|
326
353
|
}
|
|
327
354
|
const visSupportsMobileChartHeight = () => {
|
|
328
355
|
// TODO: this is a soft release. Support should eventually match visSupportsChartHeight
|
|
329
|
-
const enabledCharts = ['Bar', 'Line', 'Combo', 'Area Chart']
|
|
356
|
+
const enabledCharts = ['Bar', 'Line', 'Combo', 'Area Chart', 'Radar']
|
|
330
357
|
if (enabledCharts.includes(visualizationType)) return true
|
|
331
358
|
return false
|
|
332
359
|
}
|
|
333
360
|
|
|
334
361
|
const visSupportsLeftValueAxis = () => {
|
|
335
|
-
const disabledCharts = ['Spark Line', 'Sankey', 'Warming Stripes']
|
|
362
|
+
const disabledCharts = ['Radar', 'Spark Line', 'Sankey', 'Warming Stripes']
|
|
336
363
|
if (disabledCharts.includes(visualizationType)) return false
|
|
337
364
|
return true
|
|
338
365
|
}
|
|
@@ -417,56 +444,58 @@ export const useEditorPermissions = () => {
|
|
|
417
444
|
return {
|
|
418
445
|
enabledChartTypes,
|
|
419
446
|
visCanAnimate,
|
|
447
|
+
visHasaAdditionalLabelsOnBars,
|
|
420
448
|
visHasAnchors,
|
|
421
449
|
visHasBarBorders,
|
|
450
|
+
visHasBrushChart,
|
|
451
|
+
visHasCategoricalAxis,
|
|
422
452
|
visHasDataCutoff,
|
|
423
|
-
visHasLabelOnData,
|
|
424
453
|
visHasDataSuppression,
|
|
454
|
+
visHasLabelOnData,
|
|
425
455
|
visHasLegend,
|
|
426
456
|
visHasLegendAxisAlign,
|
|
427
457
|
visHasLegendColorCategory,
|
|
428
|
-
visHasBrushChart,
|
|
429
458
|
visHasNumbersOnBars,
|
|
430
|
-
|
|
459
|
+
visHasSelectableLegendValues,
|
|
460
|
+
visHasSingleSeriesTooltip,
|
|
431
461
|
visSupportsBarSpace,
|
|
432
462
|
visSupportsBarThickness,
|
|
433
463
|
visSupportsChartHeight,
|
|
434
|
-
|
|
464
|
+
visSupportsClickingLegend,
|
|
465
|
+
visSupportsDataAnnotations,
|
|
435
466
|
visSupportsDateCategoryAxis,
|
|
436
|
-
visSupportsDateCategoryAxisMin,
|
|
437
|
-
visSupportsDateCategoryAxisMax,
|
|
438
467
|
visSupportsDateCategoryAxisLabel,
|
|
439
468
|
visSupportsDateCategoryAxisLine,
|
|
469
|
+
visSupportsDateCategoryAxisMax,
|
|
470
|
+
visSupportsDateCategoryAxisMin,
|
|
471
|
+
visSupportsDateCategoryAxisPadding,
|
|
440
472
|
visSupportsDateCategoryAxisTicks,
|
|
441
473
|
visSupportsDateCategoryHeight,
|
|
442
474
|
visSupportsDateCategoryNumTicks,
|
|
443
475
|
visSupportsDateCategoryTickRotation,
|
|
444
|
-
|
|
476
|
+
visSupportsDynamicSeries,
|
|
445
477
|
visSupportsFilters,
|
|
446
478
|
visSupportsFootnotes,
|
|
447
479
|
visSupportsLeftValueAxis,
|
|
480
|
+
visSupportsMobileChartHeight,
|
|
448
481
|
visSupportsNonSequentialPallete,
|
|
449
482
|
visSupportsPreliminaryData,
|
|
450
483
|
visSupportsRankByValue,
|
|
484
|
+
visSupportsReactTooltip,
|
|
451
485
|
visSupportsRegions,
|
|
452
486
|
visSupportsResponsiveTicks,
|
|
453
487
|
visSupportsReverseColorPalette,
|
|
454
488
|
visSupportsSequentialPallete,
|
|
489
|
+
visSupportsSmallMultiples,
|
|
455
490
|
visSupportsSuperTitle,
|
|
456
491
|
visSupportsTooltipLines,
|
|
457
|
-
visHasSelectableLegendValues,
|
|
458
492
|
visSupportsTooltipOpacity,
|
|
459
493
|
visSupportsValueAxisGridLines,
|
|
460
494
|
visSupportsValueAxisLabels,
|
|
461
495
|
visSupportsValueAxisLine,
|
|
462
|
-
visSupportsValueAxisTicks,
|
|
463
|
-
visSupportsReactTooltip,
|
|
464
496
|
visSupportsValueAxisMax,
|
|
465
497
|
visSupportsValueAxisMin,
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
visSupportsYPadding,
|
|
469
|
-
visHasSingleSeriesTooltip,
|
|
470
|
-
visHasCategoricalAxis
|
|
498
|
+
visSupportsValueAxisTicks,
|
|
499
|
+
visSupportsYPadding
|
|
471
500
|
}
|
|
472
501
|
}
|
|
@@ -26,7 +26,7 @@ const ForestPlot = ({
|
|
|
26
26
|
handleTooltipMouseOver,
|
|
27
27
|
forestPlotRightLabelRef
|
|
28
28
|
}: ForestPlotProps) => {
|
|
29
|
-
const {
|
|
29
|
+
const { transformedData: data, updateConfig } = useContext<ChartContext>(ConfigContext)
|
|
30
30
|
const { forestPlot } = config as ChartConfig
|
|
31
31
|
const labelPosition = config.xAxis.tickWidthMax + 10
|
|
32
32
|
const [initialLogTicksSet, setInitialLogTicks] = useState(false)
|
|
@@ -90,7 +90,9 @@ const ForestPlot = ({
|
|
|
90
90
|
}
|
|
91
91
|
}, [config.forestPlot.type])
|
|
92
92
|
|
|
93
|
-
const pooledData =
|
|
93
|
+
const pooledData = data.find(d => d[config.xAxis.dataKey] === config.forestPlot.pooledResult.column)
|
|
94
|
+
const [plotStart, plotEnd] = [...xScale.range()].sort((a, b) => a - b)
|
|
95
|
+
const plotWidth = plotEnd - plotStart
|
|
94
96
|
|
|
95
97
|
const regressionPoints = pooledData
|
|
96
98
|
? [
|
|
@@ -112,12 +114,12 @@ const ForestPlot = ({
|
|
|
112
114
|
|
|
113
115
|
const topLine = [
|
|
114
116
|
{ x: 0, y: topMarginOffset },
|
|
115
|
-
{ x:
|
|
117
|
+
{ x: plotEnd, y: topMarginOffset }
|
|
116
118
|
]
|
|
117
119
|
|
|
118
120
|
const bottomLine = [
|
|
119
121
|
{ x: 0, y: height },
|
|
120
|
-
{ x:
|
|
122
|
+
{ x: plotEnd, y: height }
|
|
121
123
|
]
|
|
122
124
|
|
|
123
125
|
type Columns = {
|
|
@@ -289,30 +291,21 @@ const ForestPlot = ({
|
|
|
289
291
|
{forestPlot.regression.description}
|
|
290
292
|
</Text>
|
|
291
293
|
)}
|
|
292
|
-
|
|
293
|
-
<Bar
|
|
294
|
-
key='forest-plot-tooltip-area'
|
|
295
|
-
className='forest-plot-tooltip-area'
|
|
296
|
-
width={width}
|
|
297
|
-
height={height}
|
|
298
|
-
fill={false ? 'red' : 'transparent'}
|
|
299
|
-
fillOpacity={0.5}
|
|
300
|
-
onMouseMove={e => handleTooltipMouseOver(e, data)}
|
|
301
|
-
onMouseOut={handleTooltipMouseOff}
|
|
302
|
-
/>
|
|
303
294
|
</Group>
|
|
304
295
|
<Line
|
|
305
296
|
from={topLine[0]}
|
|
306
297
|
to={topLine[1]}
|
|
307
|
-
style={{ stroke: '
|
|
298
|
+
style={{ stroke: '#333', strokeWidth: 1 }}
|
|
308
299
|
className='forestplot__top-line'
|
|
309
300
|
/>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
301
|
+
{config.xAxis.hideAxis && (
|
|
302
|
+
<Line
|
|
303
|
+
from={bottomLine[0]}
|
|
304
|
+
to={bottomLine[1]}
|
|
305
|
+
style={{ stroke: '#333', strokeWidth: 1 }}
|
|
306
|
+
className='forestplot__bottom-line'
|
|
307
|
+
/>
|
|
308
|
+
)}
|
|
316
309
|
|
|
317
310
|
{/* column data */}
|
|
318
311
|
{columnsOnChart.map((column, colIndex) => {
|
|
@@ -400,6 +393,17 @@ const ForestPlot = ({
|
|
|
400
393
|
{forestPlot.rightLabel}
|
|
401
394
|
</Text>
|
|
402
395
|
)}
|
|
396
|
+
<Bar
|
|
397
|
+
key='forest-plot-tooltip-area'
|
|
398
|
+
className='forest-plot-tooltip-area'
|
|
399
|
+
x={0}
|
|
400
|
+
width={width}
|
|
401
|
+
height={height}
|
|
402
|
+
fill={false ? 'red' : 'transparent'}
|
|
403
|
+
fillOpacity={0.5}
|
|
404
|
+
onMouseMove={e => handleTooltipMouseOver(e, data)}
|
|
405
|
+
onMouseOut={handleTooltipMouseOff}
|
|
406
|
+
/>
|
|
403
407
|
</>
|
|
404
408
|
)
|
|
405
409
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useContext, memo, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
// cdc
|
|
4
|
+
import ConfigContext from '../../ConfigContext'
|
|
5
|
+
import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
|
|
6
|
+
import { isDateScale } from '@cdc/core/helpers/cove/date'
|
|
7
|
+
|
|
8
|
+
// visx
|
|
9
|
+
import { Bar } from '@visx/shape'
|
|
10
|
+
import { Group } from '@visx/group'
|
|
11
|
+
|
|
12
|
+
// components
|
|
13
|
+
import HorizonBand from './components/HorizonBand'
|
|
14
|
+
|
|
15
|
+
// helpers
|
|
16
|
+
import { calculateHorizonBands } from './helpers/calculateHorizonBands'
|
|
17
|
+
|
|
18
|
+
// types
|
|
19
|
+
import { HORIZON_DEFAULTS } from '../../types/Horizon'
|
|
20
|
+
|
|
21
|
+
type HorizonChartProps = {
|
|
22
|
+
xScale: any
|
|
23
|
+
yScale: any
|
|
24
|
+
xMax: number
|
|
25
|
+
yMax: number
|
|
26
|
+
handleTooltipMouseOver: (e: any, additionalData?: any) => void
|
|
27
|
+
handleTooltipMouseOff: () => void
|
|
28
|
+
tooltipData?: any
|
|
29
|
+
showTooltip?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HorizonChart = ({ xScale, xMax, yMax, handleTooltipMouseOver, handleTooltipMouseOff }: HorizonChartProps) => {
|
|
33
|
+
// Get data and config from context
|
|
34
|
+
const { transformedData: data, config, colorScale, rawData, parseDate } = useContext(ConfigContext)
|
|
35
|
+
|
|
36
|
+
const horizonConfig = {
|
|
37
|
+
...HORIZON_DEFAULTS,
|
|
38
|
+
...config.horizon
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Get series keys for rendering rows
|
|
42
|
+
const seriesKeys =
|
|
43
|
+
(config.runtime?.seriesKeys?.length ? config.runtime.seriesKeys : config.series?.map(s => s.dataKey)) || []
|
|
44
|
+
|
|
45
|
+
// Calculate value range across all horizon series (for consistent scaling)
|
|
46
|
+
// Must be called before early returns to satisfy React hooks rules
|
|
47
|
+
const valueRange = useMemo(() => {
|
|
48
|
+
if (!data || data.length === 0 || seriesKeys.length === 0) {
|
|
49
|
+
return { min: 0, max: 0 }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let min = Infinity
|
|
53
|
+
let max = -Infinity
|
|
54
|
+
|
|
55
|
+
data.forEach((row: any) => {
|
|
56
|
+
seriesKeys.forEach((key: string) => {
|
|
57
|
+
const value = Math.abs(Number(row[key]) || 0)
|
|
58
|
+
if (value > 0) {
|
|
59
|
+
min = Math.min(min, value)
|
|
60
|
+
max = Math.max(max, value)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
min: min === Infinity ? 0 : min,
|
|
67
|
+
max: max === -Infinity ? 0 : max
|
|
68
|
+
}
|
|
69
|
+
}, [data, seriesKeys])
|
|
70
|
+
|
|
71
|
+
// Early returns after all hooks
|
|
72
|
+
if (!data || data.length === 0) return null
|
|
73
|
+
if (seriesKeys.length === 0) return null
|
|
74
|
+
if (xMax <= 0 || yMax <= 0) return null
|
|
75
|
+
|
|
76
|
+
// Calculate band dimensions to fill available space
|
|
77
|
+
const { bandHeight, getRowY } = calculateHorizonBands(
|
|
78
|
+
seriesKeys.length,
|
|
79
|
+
yMax,
|
|
80
|
+
horizonConfig.bandGap,
|
|
81
|
+
horizonConfig.bottomPadding
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const getXPosition = value => {
|
|
85
|
+
if (config.xAxis.type === 'categorical') {
|
|
86
|
+
return xScale(value) + (xScale.bandwidth ? xScale.bandwidth() / 2 : 0)
|
|
87
|
+
}
|
|
88
|
+
if (isDateScale(config.xAxis)) {
|
|
89
|
+
const scaledValue = xScale(parseDate(value, false))
|
|
90
|
+
return scaledValue + (xScale.bandwidth ? xScale.bandwidth() / 2 : 0)
|
|
91
|
+
}
|
|
92
|
+
return xScale(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ErrorBoundary component='HorizonChart'>
|
|
97
|
+
<Group className='horizon-chart' key='horizon-wrapper' left={Number(config.yAxis.size)} height={Number(yMax)}>
|
|
98
|
+
{seriesKeys.map((seriesKey, index) => {
|
|
99
|
+
const rowY = getRowY(index)
|
|
100
|
+
return (
|
|
101
|
+
<Group key={seriesKey} top={rowY} className='horizon-band-row'>
|
|
102
|
+
{/* Horizon band for this series */}
|
|
103
|
+
<HorizonBand
|
|
104
|
+
data={data}
|
|
105
|
+
seriesKey={seriesKey}
|
|
106
|
+
xAxisKey={config.xAxis.dataKey}
|
|
107
|
+
getXPosition={getXPosition}
|
|
108
|
+
bandHeight={bandHeight}
|
|
109
|
+
xMax={xMax}
|
|
110
|
+
numLayers={horizonConfig.numLayers}
|
|
111
|
+
colorScale={colorScale}
|
|
112
|
+
config={config}
|
|
113
|
+
globalMax={valueRange.max}
|
|
114
|
+
/>
|
|
115
|
+
</Group>
|
|
116
|
+
)
|
|
117
|
+
})}
|
|
118
|
+
{/* Transparent bar for tooltip interaction */}
|
|
119
|
+
<Bar
|
|
120
|
+
width={Number(xMax)}
|
|
121
|
+
height={Number(yMax)}
|
|
122
|
+
fill='transparent'
|
|
123
|
+
onMouseMove={e => handleTooltipMouseOver(e, rawData)}
|
|
124
|
+
onMouseLeave={handleTooltipMouseOff}
|
|
125
|
+
/>
|
|
126
|
+
</Group>
|
|
127
|
+
</ErrorBoundary>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default memo(HorizonChart)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React, { memo, useId, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
// visx
|
|
4
|
+
import { AreaClosed } from '@visx/shape'
|
|
5
|
+
import { Group } from '@visx/group'
|
|
6
|
+
import { scaleLinear } from '@visx/scale'
|
|
7
|
+
import * as allCurves from '@visx/curve'
|
|
8
|
+
import { approvedCurveTypes } from '@cdc/core/helpers/lineChartHelpers'
|
|
9
|
+
import { getHorizonLayerColors } from '../helpers/getHorizonLayerColors'
|
|
10
|
+
|
|
11
|
+
type HorizonBandProps = {
|
|
12
|
+
data: any[]
|
|
13
|
+
seriesKey: string
|
|
14
|
+
xAxisKey: string
|
|
15
|
+
getXPosition: (value: any) => number
|
|
16
|
+
bandHeight: number
|
|
17
|
+
xMax: number
|
|
18
|
+
numLayers: number
|
|
19
|
+
colorScale: any
|
|
20
|
+
config: any
|
|
21
|
+
globalMax: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HorizonBand renders a single series as a horizon chart
|
|
26
|
+
*
|
|
27
|
+
* Horizon charts work by:
|
|
28
|
+
* 1. Dividing the value range into N layers
|
|
29
|
+
* 2. Each layer shows values within its threshold range
|
|
30
|
+
* 3. Layers are stacked/overlapped to create the horizon effect
|
|
31
|
+
* 4. Higher values appear overlapped, usually darker depending on color palette (achieved through layer stacking)
|
|
32
|
+
*/
|
|
33
|
+
const HorizonBand = ({
|
|
34
|
+
data,
|
|
35
|
+
seriesKey,
|
|
36
|
+
xAxisKey,
|
|
37
|
+
getXPosition,
|
|
38
|
+
bandHeight,
|
|
39
|
+
xMax,
|
|
40
|
+
numLayers,
|
|
41
|
+
config,
|
|
42
|
+
globalMax
|
|
43
|
+
}: HorizonBandProps) => {
|
|
44
|
+
// Create a unique, safe ID for clipPath (useId ensures uniqueness across instances)
|
|
45
|
+
// Must be called before any early returns to follow React's rules of hooks
|
|
46
|
+
const uniqueId = useId()
|
|
47
|
+
const safeSeriesKey = seriesKey.replace(/[^a-zA-Z0-9]/g, '-')
|
|
48
|
+
const clipId = `horizon-clip-${safeSeriesKey}-${uniqueId.replace(/:/g, '')}`
|
|
49
|
+
|
|
50
|
+
// Get the curve type from config (same as stacked area chart)
|
|
51
|
+
const curveType = allCurves[approvedCurveTypes[config.stackedAreaChartLineType || 'Linear']] || allCurves.curveLinear
|
|
52
|
+
|
|
53
|
+
// Process data: convert to absolute values and compute series max in single pass
|
|
54
|
+
const { processedData, seriesMax } = useMemo(() => {
|
|
55
|
+
let max = 0
|
|
56
|
+
const processed = data.map(d => {
|
|
57
|
+
const absValue = Math.abs(Number(d[seriesKey]) || 0)
|
|
58
|
+
if (absValue > max) max = absValue
|
|
59
|
+
return { ...d, [seriesKey]: absValue }
|
|
60
|
+
})
|
|
61
|
+
return { processedData: processed, seriesMax: max }
|
|
62
|
+
}, [data, seriesKey])
|
|
63
|
+
|
|
64
|
+
// Get layer colors using shared helper (memoized based on palette config and numLayers)
|
|
65
|
+
// Must be called before early returns to follow React's rules of hooks
|
|
66
|
+
const layerColors = useMemo(
|
|
67
|
+
() => getHorizonLayerColors(config, numLayers),
|
|
68
|
+
[
|
|
69
|
+
config.general?.palette?.name,
|
|
70
|
+
config.general?.palette?.isReversed,
|
|
71
|
+
config.general?.palette?.version,
|
|
72
|
+
config.general?.palette?.customColors,
|
|
73
|
+
numLayers
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Use global max for scaling (ensures all series bands are comparable)
|
|
78
|
+
const maxValue = globalMax
|
|
79
|
+
|
|
80
|
+
// If no data, max is 0, or dimensions are invalid, don't render
|
|
81
|
+
if (maxValue === 0) return null
|
|
82
|
+
if (xMax <= 0 || bandHeight <= 0) return null
|
|
83
|
+
|
|
84
|
+
// Calculate the threshold for each layer
|
|
85
|
+
// Each layer represents 1/numLayers of the max value
|
|
86
|
+
const layerThreshold = maxValue / numLayers
|
|
87
|
+
|
|
88
|
+
// Create a y-scale for positioning within the band
|
|
89
|
+
// The scale maps values 0-layerThreshold to the full bandHeight
|
|
90
|
+
// Each layer uses the full band height, creating overlay effect
|
|
91
|
+
const yScale = scaleLinear({
|
|
92
|
+
domain: [0, layerThreshold],
|
|
93
|
+
range: [bandHeight, 0],
|
|
94
|
+
clamp: true // Clamp values above threshold
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Render layers from bottom to top
|
|
98
|
+
// Each layer shows values from (layerIndex * threshold) to ((layerIndex + 1) * threshold)
|
|
99
|
+
const layers = []
|
|
100
|
+
|
|
101
|
+
for (let layerIndex = 0; layerIndex < numLayers; layerIndex++) {
|
|
102
|
+
const layerMin = layerIndex * layerThreshold
|
|
103
|
+
|
|
104
|
+
// Short-circuit: if this layer's minimum exceeds the series max,
|
|
105
|
+
// no remaining layers can have visible data
|
|
106
|
+
if (layerMin >= seriesMax) break
|
|
107
|
+
|
|
108
|
+
// Build layer data and track hasData in a single pass
|
|
109
|
+
let hasData = false
|
|
110
|
+
const layerData = processedData.map(d => {
|
|
111
|
+
const rawValue = d[seriesKey]
|
|
112
|
+
// Calculate the value relative to this layer's base
|
|
113
|
+
const layerValue = Math.max(0, rawValue - layerMin)
|
|
114
|
+
// Clamp to the layer threshold
|
|
115
|
+
const clampedValue = Math.min(layerValue, layerThreshold)
|
|
116
|
+
|
|
117
|
+
if (clampedValue > 0) hasData = true
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
x: d[xAxisKey],
|
|
121
|
+
y: clampedValue
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (!hasData) continue
|
|
126
|
+
|
|
127
|
+
// Get color for this layer from the distributed layer colors
|
|
128
|
+
const layerColor = layerColors[layerIndex]
|
|
129
|
+
|
|
130
|
+
layers.push(
|
|
131
|
+
<Group key={`layer-${layerIndex}`} top={0}>
|
|
132
|
+
<AreaClosed
|
|
133
|
+
data={layerData}
|
|
134
|
+
x={d => getXPosition(d.x)}
|
|
135
|
+
y={d => yScale(d.y)}
|
|
136
|
+
yScale={yScale}
|
|
137
|
+
curve={curveType}
|
|
138
|
+
fill={layerColor}
|
|
139
|
+
fillOpacity={1}
|
|
140
|
+
stroke='none'
|
|
141
|
+
/>
|
|
142
|
+
</Group>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Group className='horizon-band'>
|
|
148
|
+
{/* Clip to band bounds */}
|
|
149
|
+
<defs>
|
|
150
|
+
<clipPath id={clipId}>
|
|
151
|
+
<rect x={0} y={0} width={xMax} height={bandHeight} />
|
|
152
|
+
</clipPath>
|
|
153
|
+
</defs>
|
|
154
|
+
|
|
155
|
+
<Group clipPath={`url(#${clipId})`}>{layers}</Group>
|
|
156
|
+
</Group>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default memo(HorizonBand)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates the band dimensions for horizon chart rows
|
|
3
|
+
* Used by both HorizonChart (for rendering) and LeftAxis (for label positioning)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MIN_BAND_HEIGHT = 10
|
|
7
|
+
|
|
8
|
+
export type HorizonBandCalculation = {
|
|
9
|
+
bandHeight: number
|
|
10
|
+
getRowY: (index: number) => number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function calculateHorizonBands(
|
|
14
|
+
numSeries: number,
|
|
15
|
+
yMax: number,
|
|
16
|
+
bandGap: number | string,
|
|
17
|
+
bottomPadding: number | string = 15
|
|
18
|
+
): HorizonBandCalculation {
|
|
19
|
+
const gap = Number(bandGap) || 0
|
|
20
|
+
const padding = Number(bottomPadding) || 0
|
|
21
|
+
|
|
22
|
+
const totalGapSpace = (numSeries - 1) * gap + padding
|
|
23
|
+
const bandHeight = Math.max((yMax - totalGapSpace) / numSeries, MIN_BAND_HEIGHT)
|
|
24
|
+
const getRowY = (index: number) => index * (bandHeight + gap)
|
|
25
|
+
|
|
26
|
+
return { bandHeight, getRowY }
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
|
|
2
|
+
import { v2ColorDistribution } from '@cdc/core/helpers/palettes/colorDistributions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculates the layer colors for a horizon chart based on palette configuration.
|
|
6
|
+
* Shared between HorizonBand rendering and Legend display.
|
|
7
|
+
*/
|
|
8
|
+
export const getHorizonLayerColors = (config: any, numLayers: number): string[] => {
|
|
9
|
+
const paletteName = config.general?.palette?.name || 'sequential_blue'
|
|
10
|
+
const colorPalettes = filterChartColorPalettes(config)
|
|
11
|
+
const fullPalette = colorPalettes[paletteName] || Object.values(colorPalettes)[0] || ['#4292c6']
|
|
12
|
+
|
|
13
|
+
// Use v2ColorDistribution if we have a 9-color palette and numLayers <= 9
|
|
14
|
+
if (fullPalette.length === 9 && numLayers <= 9 && v2ColorDistribution[numLayers]) {
|
|
15
|
+
const indices = v2ColorDistribution[numLayers]
|
|
16
|
+
return indices.map((i: number) => fullPalette[i])
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Fallback: take first numLayers colors, or repeat if needed
|
|
20
|
+
return Array.from({ length: numLayers }, (_, i) => fullPalette[i % fullPalette.length])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Calculates the max value across all series in a horizon chart dataset.
|
|
25
|
+
* Used for consistent scaling across all bands.
|
|
26
|
+
*/
|
|
27
|
+
export const getHorizonMaxValue = (data: any[], seriesKeys: string[]): number => {
|
|
28
|
+
if (!data || data.length === 0 || !seriesKeys || seriesKeys.length === 0) {
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let max = 0
|
|
33
|
+
for (const row of data) {
|
|
34
|
+
for (const key of seriesKeys) {
|
|
35
|
+
const value = Math.abs(Number(row[key]) || 0)
|
|
36
|
+
if (value > max) max = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return max
|
|
40
|
+
}
|