@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
@@ -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
- 'Spark Line',
96
+ 'Radar',
81
97
  'Sankey',
82
- 'Bump Chart',
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), and vertical Area charts
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 = ['Forest Plot', 'Pie', 'Paired Bar', 'Spark Line', 'Sankey', 'Warming Stripes']
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
- visHasaAdditionalLabelsOnBars,
459
+ visHasSelectableLegendValues,
460
+ visHasSingleSeriesTooltip,
431
461
  visSupportsBarSpace,
432
462
  visSupportsBarThickness,
433
463
  visSupportsChartHeight,
434
- visSupportsMobileChartHeight,
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
- visSupportsDateCategoryAxisPadding,
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
- visSupportsDynamicSeries,
467
- visSupportsSmallMultiples,
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 { rawData: data, updateConfig } = useContext<ChartContext>(ConfigContext)
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 = config.data.find(d => d[config.xAxis.dataKey] === config.forestPlot.pooledResult.column)
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: width, y: topMarginOffset }
117
+ { x: plotEnd, y: topMarginOffset }
116
118
  ]
117
119
 
118
120
  const bottomLine = [
119
121
  { x: 0, y: height },
120
- { x: width, y: height }
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: 'black', strokeWidth: 2 }}
298
+ style={{ stroke: '#333', strokeWidth: 1 }}
308
299
  className='forestplot__top-line'
309
300
  />
310
- <Line
311
- from={bottomLine[0]}
312
- to={bottomLine[1]}
313
- style={{ stroke: 'black', strokeWidth: 2 }}
314
- className='forestplot__bottom-line'
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
+ }
@@ -0,0 +1,3 @@
1
+ import HorizonChart from './HorizonChart'
2
+
3
+ export { HorizonChart }