@cdc/chart 4.26.2 → 4.26.4

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 (165) hide show
  1. package/CONFIG.md +169 -0
  2. package/README.md +42 -17
  3. package/dist/cdcchart.js +39419 -35180
  4. package/examples/__data__/data-with-metadata.json +10 -0
  5. package/examples/feature/bar/additional-column-tooltip.json +3 -3
  6. package/examples/feature/deviation/planet-deviation-config.json +2 -2
  7. package/examples/feature/paired-bar/paired-bar-example.json +2 -2
  8. package/examples/feature/pie/planet-pie-example-config.json +2 -1
  9. package/examples/feature/tests-case-rate/case-rate-example-config.json +2 -2
  10. package/examples/feature/tests-covid/covid-confidence-example-config.json +2 -2
  11. package/examples/feature/tests-covid/covid-example-config.json +2 -2
  12. package/examples/feature/tests-cutoff/cutoff-example-config.json +2 -2
  13. package/examples/feature/tests-non-numerics/planet-pie-example-config-nonnumeric.json +2 -2
  14. package/examples/metadata-variables.json +58 -0
  15. package/examples/minimal-example.json +24 -0
  16. package/examples/private/test2.json +35012 -0
  17. package/package.json +4 -4
  18. package/src/CdcChart.tsx +8 -4
  19. package/src/CdcChartComponent.tsx +326 -290
  20. package/src/_stories/Chart.CustomColors.smoke.stories.tsx +97 -0
  21. package/src/_stories/Chart.Defaults.smoke.stories.tsx +95 -0
  22. package/src/_stories/{Chart.HTMLInDataTable.stories.tsx → Chart.HTMLInDataTable.smoke.stories.tsx} +10 -0
  23. package/src/_stories/Chart.Minimal.smoke.stories.tsx +25 -0
  24. package/src/_stories/{Chart.SmallMultiples.stories.tsx → Chart.SmallMultiples.smoke.stories.tsx} +17 -1
  25. package/src/_stories/Chart.SmallestLeftAxisMax.smoke.stories.tsx +64 -0
  26. package/src/_stories/{Chart.stories.tsx → Chart.smoke.stories.tsx} +48 -2
  27. package/src/_stories/{Chart.tooltip.stories.tsx → Chart.tooltip.smoke.stories.tsx} +68 -0
  28. package/src/_stories/ChartBar.Editor.stories.tsx +177 -115
  29. package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
  30. package/src/_stories/ChartEditor.Editor.stories.tsx +24 -1
  31. package/src/_stories/{Chart.CustomColors.stories.tsx → ChartLine.DashedDates.smoke.stories.tsx} +4 -4
  32. package/src/_stories/_mock/line_chart_dashed_dates.json +276 -0
  33. package/src/_stories/_mock/paired-bar-abbr.json +421 -0
  34. package/src/_stories/_mock/paired-bar.json +1 -1
  35. package/src/_stories/_mock/pie_custom_colors.json +268 -0
  36. package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
  37. package/src/components/Annotations/components/AnnotationDraggable.styles.css +10 -10
  38. package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
  39. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -1
  40. package/src/components/Annotations/components/AnnotationList.styles.css +11 -11
  41. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -2
  42. package/src/components/Axis/BottomAxis.tsx +10 -3
  43. package/src/components/Axis/LeftAxis.tsx +2 -4
  44. package/src/components/Axis/LeftAxisGridlines.tsx +1 -3
  45. package/src/components/Axis/PairedBarAxis.tsx +10 -4
  46. package/src/components/BarChart/components/BarChart.Horizontal.tsx +21 -33
  47. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +21 -35
  48. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +21 -36
  49. package/src/components/BarChart/components/BarChart.Vertical.tsx +21 -34
  50. package/src/components/BarChart/components/BarChart.tsx +2 -6
  51. package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
  52. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
  53. package/src/components/BarChart/helpers/useBarChart.ts +4 -1
  54. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +3 -3
  55. package/src/components/Brush/BrushSelector.tsx +2 -1
  56. package/src/components/Brush/MiniChartPreview.tsx +21 -26
  57. package/src/components/EditorPanel/EditorPanel.tsx +692 -954
  58. package/src/components/EditorPanel/EditorPanelContext.ts +1 -1
  59. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +218 -167
  60. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
  61. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +39 -1
  62. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +169 -135
  63. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +154 -109
  64. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +3 -3
  65. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +95 -28
  66. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +45 -42
  67. package/src/components/EditorPanel/editor-panel.scss +1 -1
  68. package/src/components/EditorPanel/useEditorPermissions.ts +1 -10
  69. package/src/components/Forecasting/Forecasting.tsx +2 -2
  70. package/src/components/ForestPlot/ForestPlot.tsx +26 -22
  71. package/src/components/HorizonChart/HorizonChart.tsx +10 -2
  72. package/src/components/HoverLine/HoverLine.tsx +6 -8
  73. package/src/components/Legend/Legend.Component.tsx +4 -4
  74. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +9 -4
  75. package/src/components/Legend/LegendGroup/LegendGroup.tsx +3 -1
  76. package/src/components/Legend/helpers/createFormatLabels.tsx +3 -2
  77. package/src/components/Legend/helpers/index.ts +0 -2
  78. package/src/components/LineChart/LineChartProps.ts +1 -0
  79. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +3 -2
  80. package/src/components/LineChart/index.tsx +11 -4
  81. package/src/components/LinearChart/VisualizationRenderer.tsx +11 -1
  82. package/src/components/LinearChart/tests/LinearChart.test.tsx +224 -0
  83. package/src/components/LinearChart/tests/mockConfigContext.ts +2 -0
  84. package/src/components/LinearChart.tsx +124 -47
  85. package/src/components/PairedBarChart.jsx +2 -2
  86. package/src/components/PieChart/PieChart.tsx +30 -13
  87. package/src/components/RadarChart/RadarChart.tsx +1 -1
  88. package/src/components/Regions/components/Regions.tsx +36 -26
  89. package/src/components/Sankey/components/Sankey.tsx +3 -3
  90. package/src/components/Sankey/sankey.scss +1 -1
  91. package/src/components/ScatterPlot/ScatterPlot.jsx +19 -17
  92. package/src/components/SmallMultiples/SmallMultipleTile.tsx +1 -0
  93. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  94. package/src/components/Sparkline/components/SparkLine.tsx +1 -1
  95. package/src/components/Sparkline/index.scss +4 -2
  96. package/src/components/WarmingStripes/WarmingStripes.tsx +5 -3
  97. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
  98. package/src/data/initial-state.js +24 -14
  99. package/src/data/legacy-defaults.ts +18 -0
  100. package/src/helpers/abbreviateNumber.ts +24 -17
  101. package/src/helpers/calculateLeftYAxisWidth.ts +193 -0
  102. package/src/helpers/getChartPatternId.ts +17 -0
  103. package/src/helpers/getMinMax.ts +16 -2
  104. package/src/helpers/seriesColumnSettings.ts +114 -0
  105. package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
  106. package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
  107. package/src/helpers/tests/tooltipHelpers.test.ts +151 -0
  108. package/src/helpers/tooltipHelpers.ts +176 -0
  109. package/src/hooks/useHighlightedBars.ts +9 -16
  110. package/src/hooks/useProgrammaticTooltip.ts +16 -2
  111. package/src/hooks/useRightAxis.ts +14 -0
  112. package/src/hooks/useScales.ts +92 -56
  113. package/src/hooks/useSmallMultipleSynchronization.ts +3 -4
  114. package/src/hooks/useTooltip.tsx +186 -40
  115. package/src/scss/main.scss +152 -79
  116. package/src/test/CdcChart.test.jsx +26 -2
  117. package/src/types/ChartConfig.ts +5 -0
  118. package/tests/fixtures/chart-config-with-metadata.json +29 -0
  119. package/tests/fixtures/data-with-metadata.json +10 -0
  120. package/examples/chart-regression-2.json +0 -2360
  121. package/examples/feature/__data__/age-adjusted-rates.json +0 -1486
  122. package/examples/feature/__data__/city-temperature.json +0 -2198
  123. package/examples/feature/bar/Barchart_with_negative.json +0 -34
  124. package/examples/feature/boxplot/box-plot-data.json +0 -71
  125. package/examples/feature/boxplot/boxplot-data.json +0 -156
  126. package/examples/feature/combo/combochart-categories_are_numbers .json +0 -18
  127. package/examples/feature/forest-plot/log.json +0 -26
  128. package/examples/feature/tests-date-exclusions/date-exclusions-data.json +0 -162
  129. package/examples/region-issue.json +0 -2065
  130. package/examples/xaxis.json +0 -493
  131. /package/examples/feature/bar/{tall-data.json → __data__/tall-data.json} +0 -0
  132. /package/examples/feature/deviation/{planet-deviation-data.json → __data__/planet-deviation-data.json} +0 -0
  133. /package/examples/feature/paired-bar/{paired-bar-data.json → __data__/paired-bar-data.json} +0 -0
  134. /package/examples/feature/paired-bar/{paired-bar-formatted.json → __data__/paired-bar-formatted.json} +0 -0
  135. /package/examples/feature/tests-case-rate/{case-rate-example-data.json → __data__/case-rate-example-data.json} +0 -0
  136. /package/examples/feature/tests-covid/{covid-example-data-confidence.json → __data__/covid-example-data-confidence.json} +0 -0
  137. /package/examples/feature/tests-covid/{covid-example-data.json → __data__/covid-example-data.json} +0 -0
  138. /package/examples/feature/tests-cutoff/{cutoff-example-data.json → __data__/cutoff-example-data.json} +0 -0
  139. /package/examples/feature/tests-non-numerics/{planet-example-data-nonnumeric.json → __data__/planet-example-data-nonnumeric.json} +0 -0
  140. /package/src/_stories/{Chart.Anchors.stories.tsx → Chart.Anchors.smoke.stories.tsx} +0 -0
  141. /package/src/_stories/{Chart.BoxPlot.stories.tsx → Chart.BoxPlot.smoke.stories.tsx} +0 -0
  142. /package/src/_stories/{Chart.CI.stories.tsx → Chart.CI.smoke.stories.tsx} +0 -0
  143. /package/src/_stories/{Chart.Combo.stories.tsx → Chart.Combo.smoke.stories.tsx} +0 -0
  144. /package/src/_stories/{Chart.DynamicSeries.stories.tsx → Chart.DynamicSeries.smoke.stories.tsx} +0 -0
  145. /package/src/_stories/{Chart.Filters.stories.tsx → Chart.Filters.smoke.stories.tsx} +0 -0
  146. /package/src/_stories/{Chart.Forecast.stories.tsx → Chart.Forecast.smoke.stories.tsx} +0 -0
  147. /package/src/_stories/{Chart.Legend.Gradient.stories.tsx → Chart.Legend.Gradient.smoke.stories.tsx} +0 -0
  148. /package/src/_stories/{Chart.Patterns.stories.tsx → Chart.Patterns.smoke.stories.tsx} +0 -0
  149. /package/src/_stories/{Chart.PreserveDecimals.stories.tsx → Chart.PreserveDecimals.smoke.stories.tsx} +0 -0
  150. /package/src/_stories/{Chart.Regions.Categorical.stories.tsx → Chart.Regions.Categorical.smoke.stories.tsx} +0 -0
  151. /package/src/_stories/{Chart.Regions.DateScale.stories.tsx → Chart.Regions.DateScale.smoke.stories.tsx} +0 -0
  152. /package/src/_stories/{Chart.Regions.DateTimeScale.stories.tsx → Chart.Regions.DateTimeScale.smoke.stories.tsx} +0 -0
  153. /package/src/_stories/{Chart.ScatterPlot.stories.tsx → Chart.ScatterPlot.smoke.stories.tsx} +0 -0
  154. /package/src/_stories/{ChartAnnotation.stories.tsx → ChartAnnotation.smoke.stories.tsx} +0 -0
  155. /package/src/_stories/{ChartAxisLabels.stories.tsx → ChartAxisLabels.smoke.stories.tsx} +0 -0
  156. /package/src/_stories/{ChartAxisTitles.stories.tsx → ChartAxisTitles.smoke.stories.tsx} +0 -0
  157. /package/src/_stories/{ChartBrush.Matrix.Continuous.stories.tsx → ChartBrush.Matrix.Continuous.smoke.stories.tsx} +0 -0
  158. /package/src/_stories/{ChartBrush.Matrix.Date.stories.tsx → ChartBrush.Matrix.Date.smoke.stories.tsx} +0 -0
  159. /package/src/_stories/{ChartBrush.Matrix.DateTime.stories.tsx → ChartBrush.Matrix.DateTime.smoke.stories.tsx} +0 -0
  160. /package/src/_stories/{ChartBrush.stories.tsx → ChartBrush.smoke.stories.tsx} +0 -0
  161. /package/src/_stories/{ChartLine.QuadrantAngles.stories.tsx → ChartLine.QuadrantAngles.smoke.stories.tsx} +0 -0
  162. /package/src/_stories/{ChartLine.Suppression.stories.tsx → ChartLine.Suppression.smoke.stories.tsx} +0 -0
  163. /package/src/_stories/{ChartLine.Symbols.stories.tsx → ChartLine.Symbols.smoke.stories.tsx} +0 -0
  164. /package/src/_stories/{ChartPrefixSuffix.stories.tsx → ChartPrefixSuffix.smoke.stories.tsx} +0 -0
  165. /package/src/_stories/{TechAdoptionWithLinks.stories.tsx → TechAdoptionWithLinks.smoke.stories.tsx} +0 -0
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildTooltipListHtml,
4
+ getTooltipMarkerColor,
5
+ getTooltipMarkerShape,
6
+ shouldUseTooltipLegendMarkers,
7
+ tooltipHasMarkerColumn,
8
+ tooltipShouldUseMarkerColumn,
9
+ type TooltipRow
10
+ } from '../tooltipHelpers'
11
+
12
+ const buildConfig = overrides => ({
13
+ visualizationType: 'Line',
14
+ legend: {
15
+ hide: false,
16
+ style: 'circles',
17
+ groupBy: '',
18
+ patterns: {},
19
+ ...overrides?.legend
20
+ },
21
+ runtime: {
22
+ seriesLabels: { cases: 'Cases', deaths: 'Deaths' },
23
+ seriesLabelsAll: ['Cases', 'Deaths'],
24
+ seriesKeys: ['cases', 'deaths'],
25
+ ...overrides?.runtime
26
+ },
27
+ series: [{ dataKey: 'cases' }, { dataKey: 'deaths' }],
28
+ smallMultiples: {
29
+ mode: '',
30
+ ...overrides?.smallMultiples
31
+ },
32
+ ...overrides
33
+ })
34
+
35
+ describe('tooltipHelpers', () => {
36
+ it('enables marker rendering for supported multi-series charts with visible legends', () => {
37
+ expect(shouldUseTooltipLegendMarkers(buildConfig())).toBe(true)
38
+ })
39
+
40
+ it('allows small-multiple tiles to use markers when the source legend is visible', () => {
41
+ expect(shouldUseTooltipLegendMarkers(buildConfig({ legend: { hide: true, tooltipLegendVisible: true } }))).toBe(
42
+ true
43
+ )
44
+ })
45
+
46
+ it('disables markers when the source legend is hidden or the legend is in a special mode', () => {
47
+ expect(shouldUseTooltipLegendMarkers(buildConfig({ legend: { hide: true } }))).toBe(false)
48
+ expect(shouldUseTooltipLegendMarkers(buildConfig({ legend: { hide: true, tooltipLegendVisible: false } }))).toBe(
49
+ false
50
+ )
51
+ expect(shouldUseTooltipLegendMarkers(buildConfig({ legend: { style: 'gradient' } }))).toBe(false)
52
+ expect(shouldUseTooltipLegendMarkers(buildConfig({ legend: { groupBy: 'category' } }))).toBe(false)
53
+ expect(
54
+ shouldUseTooltipLegendMarkers(
55
+ buildConfig({
56
+ runtime: { seriesLabelsAll: ['Cases'], seriesKeys: ['cases'] },
57
+ series: [{ dataKey: 'cases' }]
58
+ })
59
+ )
60
+ ).toBe(false)
61
+ })
62
+
63
+ it('disables markers for single-series small-multiple tiles even when the source legend is visible', () => {
64
+ expect(
65
+ shouldUseTooltipLegendMarkers(
66
+ buildConfig({
67
+ legend: { hide: true, tooltipLegendVisible: true },
68
+ runtime: { seriesLabelsAll: ['Cases'], seriesKeys: ['cases'] },
69
+ series: [{ dataKey: 'cases' }]
70
+ })
71
+ )
72
+ ).toBe(false)
73
+ })
74
+
75
+ it('keeps patterned legends eligible and still uses the base series color', () => {
76
+ expect(shouldUseTooltipLegendMarkers(buildConfig({ legend: { patterns: { striped: { color: '#000' } } } }))).toBe(
77
+ true
78
+ )
79
+ })
80
+
81
+ it('resolves marker colors using the legend label mapping', () => {
82
+ const colorScale = value => ({ Cases: '#1f77b4', deaths: '#d62728' }[value] || null)
83
+
84
+ expect(getTooltipMarkerColor(buildConfig(), colorScale as any, 'cases')).toBe('#1f77b4')
85
+ expect(getTooltipMarkerColor(buildConfig(), colorScale as any, 'unknown')).toBe(null)
86
+ })
87
+
88
+ it('uses square markers only when the legend explicitly uses boxes', () => {
89
+ expect(getTooltipMarkerShape(buildConfig())).toBe('circle')
90
+ expect(getTooltipMarkerShape(buildConfig({ legend: { style: 'boxes' } }))).toBe('square')
91
+ })
92
+
93
+ it('only reserves a marker column when at least one series row has a marker', () => {
94
+ const rows: TooltipRow[] = [
95
+ { key: 'Date', value: '2024-01-01', kind: 'heading' },
96
+ { key: 'Cases', value: '10', kind: 'series', markerColor: '#1f77b4' },
97
+ { key: 'Source', value: 'CDC', kind: 'extra' }
98
+ ]
99
+ const rowsWithoutMarkers: TooltipRow[] = [
100
+ { key: 'Date', value: '2024-01-01', kind: 'heading' },
101
+ { key: 'Source', value: 'CDC', kind: 'extra' }
102
+ ]
103
+
104
+ expect(tooltipHasMarkerColumn(rows)).toBe(true)
105
+ expect(tooltipHasMarkerColumn(rowsWithoutMarkers)).toBe(false)
106
+ })
107
+
108
+ it('suppresses the marker column when two series would render identical marker signatures', () => {
109
+ expect(
110
+ tooltipShouldUseMarkerColumn([
111
+ { markerColor: '#f28e2b', markerShape: 'circle' },
112
+ { markerColor: '#f28e2b', markerShape: 'circle' }
113
+ ])
114
+ ).toBe(false)
115
+
116
+ expect(
117
+ tooltipShouldUseMarkerColumn([
118
+ { markerColor: '#f28e2b', markerShape: 'circle' },
119
+ { markerColor: '#f28e2b', markerShape: 'square' }
120
+ ])
121
+ ).toBe(true)
122
+ })
123
+
124
+ it('renders aligned raw HTML rows only when the tooltip contains a marker', () => {
125
+ const withMarker = buildTooltipListHtml({
126
+ heading: 'Date: Jan 1, 2024',
127
+ bodyRows: [{ text: 'Cases: 10', markerColor: '#1f77b4', markerShape: 'square' }, { text: 'Source: CDC' }]
128
+ })
129
+ const withoutMarker = buildTooltipListHtml({
130
+ heading: 'Date: Jan 1, 2024',
131
+ bodyRows: [{ text: 'Source: CDC' }]
132
+ })
133
+ const ambiguousMarkers = buildTooltipListHtml({
134
+ heading: 'Date: Jan 1, 2024',
135
+ bodyRows: [
136
+ { text: 'Dose 1: 10', markerColor: '#f28e2b', markerShape: 'circle' },
137
+ { text: 'Dose 2: 20', markerColor: '#f28e2b', markerShape: 'circle' }
138
+ ]
139
+ })
140
+
141
+ expect(withMarker).toContain('tooltip-body--marker-layout')
142
+ expect(withMarker).toContain('tooltip-marker-slot')
143
+ expect(withMarker).toContain('tooltip-marker-slot" aria-hidden="true"')
144
+ expect(withMarker).toContain('tooltip-marker-swatch')
145
+ expect(withMarker).toContain('tooltip-marker-swatch--square')
146
+ expect(withoutMarker).not.toContain('tooltip-body--marker-layout')
147
+ expect(withoutMarker).not.toContain('tooltip-marker-slot')
148
+ expect(ambiguousMarkers).not.toContain('tooltip-body--marker-layout')
149
+ expect(ambiguousMarkers).not.toContain('tooltip-marker-slot')
150
+ })
151
+ })
@@ -0,0 +1,176 @@
1
+ import { type ChartConfig } from '../types/ChartConfig'
2
+ import { type ColorScale } from '../types/ChartContext'
3
+
4
+ export type TooltipRowKind = 'heading' | 'series' | 'extra'
5
+
6
+ export type TooltipRow = {
7
+ key: string
8
+ value: string | number
9
+ axisPosition?: string
10
+ kind: TooltipRowKind
11
+ markerColor?: string | null
12
+ markerShape?: 'circle' | 'square'
13
+ seriesKey?: string
14
+ }
15
+
16
+ export type TooltipDisplayData = {
17
+ data: TooltipRow[]
18
+ dataXPosition: number
19
+ dataYPosition: number
20
+ useMarkerColumn: boolean
21
+ }
22
+
23
+ type RawTooltipBodyRow = {
24
+ text: string
25
+ markerColor?: string | null
26
+ markerShape?: 'circle' | 'square'
27
+ }
28
+
29
+ export type TooltipMarker = {
30
+ markerColor: string
31
+ markerShape: 'circle' | 'square'
32
+ }
33
+
34
+ const TOOLTIP_MARKER_SUPPORTED_TYPES = new Set(['Line', 'Area Chart', 'Combo', 'Bar', 'Scatter Plot'])
35
+
36
+ export const getTooltipMarkerShape = (config: ChartConfig): 'circle' | 'square' =>
37
+ config.legend?.style === 'boxes' ? 'square' : 'circle'
38
+
39
+ export const shouldUseTooltipLegendMarkers = (config: ChartConfig): boolean => {
40
+ const legendItemCount =
41
+ config.runtime?.seriesLabelsAll?.length || config.runtime?.seriesKeys?.length || config.series?.length || 0
42
+ const sourceLegendVisible = config.legend?.tooltipLegendVisible ?? !config.legend?.hide
43
+
44
+ return (
45
+ TOOLTIP_MARKER_SUPPORTED_TYPES.has(config.visualizationType) &&
46
+ legendItemCount > 1 &&
47
+ sourceLegendVisible &&
48
+ config.legend?.style !== 'gradient' &&
49
+ !config.legend?.groupBy
50
+ )
51
+ }
52
+
53
+ export const getTooltipMarkerColor = (
54
+ config: ChartConfig,
55
+ colorScale: ColorScale | undefined,
56
+ seriesKey?: string
57
+ ): string | null => {
58
+ if (!colorScale || !seriesKey) return null
59
+
60
+ const colorInput = config.runtime?.seriesLabels?.[seriesKey] || seriesKey
61
+ const color = colorScale(colorInput)
62
+ return typeof color === 'string' ? color : null
63
+ }
64
+
65
+ export const getTooltipSeriesMarker = (
66
+ config: ChartConfig,
67
+ colorScale: ColorScale | undefined,
68
+ seriesKey?: string
69
+ ): TooltipMarker | null => {
70
+ if (!shouldUseTooltipLegendMarkers(config)) {
71
+ return null
72
+ }
73
+
74
+ const markerColor = getTooltipMarkerColor(config, colorScale, seriesKey)
75
+ if (!markerColor) {
76
+ return null
77
+ }
78
+
79
+ return {
80
+ markerColor,
81
+ markerShape: getTooltipMarkerShape(config)
82
+ }
83
+ }
84
+
85
+ export const buildTooltipRow = (row: TooltipRow): TooltipRow => row
86
+
87
+ const getMarkerSignature = ({
88
+ markerColor,
89
+ markerShape = 'circle'
90
+ }: {
91
+ markerColor?: string | null
92
+ markerShape?: 'circle' | 'square'
93
+ }): string | null => {
94
+ if (!markerColor) return null
95
+ return `${markerColor}::${markerShape}`
96
+ }
97
+
98
+ export const tooltipShouldUseMarkerColumn = (
99
+ rows: Array<{ markerColor?: string | null; markerShape?: 'circle' | 'square' }>
100
+ ): boolean => {
101
+ const signatures = rows.map(getMarkerSignature).filter(Boolean)
102
+
103
+ if (signatures.length === 0) {
104
+ return false
105
+ }
106
+
107
+ return new Set(signatures).size === signatures.length
108
+ }
109
+
110
+ export const tooltipHasMarkerColumn = (rows: TooltipRow[]): boolean =>
111
+ tooltipShouldUseMarkerColumn(rows.filter(row => row.kind === 'series'))
112
+
113
+ export const buildTooltipBodyRowHtml = ({
114
+ text,
115
+ markerColor,
116
+ markerShape = 'circle',
117
+ useMarkerColumn
118
+ }: RawTooltipBodyRow & {
119
+ useMarkerColumn: boolean
120
+ }): string => {
121
+ if (!useMarkerColumn) {
122
+ return `<li class="tooltip-body">${text}</li>`
123
+ }
124
+
125
+ const marker = markerColor
126
+ ? `<span class="tooltip-marker-swatch tooltip-marker-swatch--${markerShape}" style="background-color: ${markerColor};" aria-hidden="true"></span>`
127
+ : ''
128
+
129
+ return `<li class="tooltip-body tooltip-body--marker-layout"><span class="tooltip-marker-slot" aria-hidden="true">${marker}</span><span class="tooltip-body-content">${text}</span></li>`
130
+ }
131
+
132
+ export const buildTooltipListHtml = ({
133
+ heading,
134
+ bodyRows
135
+ }: {
136
+ heading: string
137
+ bodyRows: RawTooltipBodyRow[]
138
+ }): string => {
139
+ const hasMarkerColumn = tooltipShouldUseMarkerColumn(bodyRows)
140
+ const renderedRows = bodyRows
141
+ .filter(row => row.text !== undefined && row.text !== null && row.text !== '')
142
+ .map(row => buildTooltipBodyRowHtml({ ...row, useMarkerColumn: hasMarkerColumn }))
143
+ .join('')
144
+
145
+ return `<ul><li class="tooltip-heading">${heading}</li>${renderedRows}</ul>`
146
+ }
147
+
148
+ export const buildSeriesTooltipListHtml = ({
149
+ config,
150
+ colorScale,
151
+ heading,
152
+ seriesKey,
153
+ seriesText,
154
+ extraRows = []
155
+ }: {
156
+ config: ChartConfig
157
+ colorScale?: ColorScale
158
+ heading: string
159
+ seriesKey?: string
160
+ seriesText: string
161
+ extraRows?: string[]
162
+ }): string => {
163
+ const marker = getTooltipSeriesMarker(config, colorScale, seriesKey)
164
+
165
+ return buildTooltipListHtml({
166
+ heading,
167
+ bodyRows: [
168
+ {
169
+ text: seriesText,
170
+ markerColor: marker?.markerColor,
171
+ markerShape: marker?.markerShape
172
+ },
173
+ ...extraRows.filter(Boolean).map(text => ({ text }))
174
+ ]
175
+ })
176
+ }
@@ -5,21 +5,14 @@ import { ChartConfig } from '../types/ChartConfig'
5
5
  export const useHighlightedBars = (config: ChartConfig, updateConfig: (config) => void) => {
6
6
  const { formatDate, parseDate } = useContext(ConfigContext)
7
7
 
8
- let highlightedSeries = [] // only allow single series for highlights
9
- let highlightedSeriesKey = ''
10
- let highlightedBarValues = []
11
- let highlightedSeriesValues = []
12
-
13
- if (config.series?.length > 0 && config.data) {
14
- highlightedSeries = config.series[0] // only allow single series for highlights
15
- highlightedSeriesKey = config.series[0].dataKey
16
- highlightedBarValues = config.highlightedBarValues
17
- highlightedSeriesValues = config.data.map(item => item[config.xAxis.dataKey])
18
- } else {
19
- highlightedSeries = [] // only allow single series for highlights
20
- highlightedSeriesKey = ''
21
- highlightedBarValues = []
22
- highlightedSeriesValues = []
8
+ const highlightedSeries = config.series?.length > 0 && config.data ? config.series[0] : [] // only allow single series for highlights
9
+ const highlightedSeriesKey = config.series?.length > 0 && config.data ? config.series[0].dataKey : ''
10
+ const highlightedBarValues = config.series?.length > 0 && config.data ? config.highlightedBarValues : []
11
+
12
+ const getHighlightedSeriesValues = () => {
13
+ if (!(config.series?.length > 0 && config.data)) return []
14
+
15
+ return config.data.map(item => item[config.xAxis.dataKey])
23
16
  }
24
17
 
25
18
  const handleUpdateHighlightedBorderWidth = (e, index) => {
@@ -144,7 +137,7 @@ export const useHighlightedBars = (config: ChartConfig, updateConfig: (config) =
144
137
  highlightedSeries,
145
138
  highlightedSeriesKey,
146
139
  highlightedBarValues,
147
- highlightedSeriesValues,
140
+ getHighlightedSeriesValues,
148
141
  handleUpdateHighlightedBar,
149
142
  handleAddNewHighlightedBar,
150
143
  handleRemoveHighlightedBar,
@@ -3,7 +3,9 @@ import { useRef, useImperativeHandle, ForwardedRef } from 'react'
3
3
  interface UseProgrammaticTooltipProps {
4
4
  svgRef: ForwardedRef<SVGAElement>
5
5
  getCoordinateFromXValue: (xAxisValue: any) => number
6
+ getXValueFromCoordinate: (xCoordinate: number) => any
6
7
  config: any
8
+ yAxisWidth: number
7
9
  setPoint: (point: { x: number; y: number }) => void
8
10
  setShowHoverLine: (show: boolean) => void
9
11
  handleTooltipMouseOver: (event: MouseEvent, additionalChartData?: any) => void
@@ -18,7 +20,9 @@ interface UseProgrammaticTooltipProps {
18
20
  export const useProgrammaticTooltip = ({
19
21
  svgRef,
20
22
  getCoordinateFromXValue,
23
+ getXValueFromCoordinate,
21
24
  config,
25
+ yAxisWidth,
22
26
  setPoint,
23
27
  setShowHoverLine,
24
28
  handleTooltipMouseOver,
@@ -62,7 +66,16 @@ export const useProgrammaticTooltip = ({
62
66
  }
63
67
 
64
68
  const pixelX = getCoordinateFromXValue(xAxisValue)
65
- const adjustedX = pixelX + Number(config.yAxis.size || 0)
69
+ const resolvedXValue = Number.isFinite(pixelX) ? getXValueFromCoordinate(pixelX) : null
70
+
71
+ if (!Number.isFinite(pixelX) || resolvedXValue !== xAxisValue) {
72
+ hideTooltip()
73
+ setShowHoverLine(false)
74
+ setSynchronizedXValue?.(null)
75
+ return
76
+ }
77
+
78
+ const adjustedX = pixelX + yAxisWidth
66
79
 
67
80
  const svgRect = internalSvgRef.current!.getBoundingClientRect()
68
81
 
@@ -103,7 +116,8 @@ export const useProgrammaticTooltip = ({
103
116
  },
104
117
  [
105
118
  getCoordinateFromXValue,
106
- config.yAxis.size,
119
+ getXValueFromCoordinate,
120
+ yAxisWidth,
107
121
  config.visualizationType,
108
122
  setPoint,
109
123
  setShowHoverLine,
@@ -27,6 +27,20 @@ export default function useRightAxis({ config, yMax = 0, data = [] }) {
27
27
  minValue = config.yAxis.rightMin
28
28
  }
29
29
 
30
+ // Enforce smallest right axis max so small-data charts don't show misleading decimal ticks
31
+ const smallestRightAxisMaxRaw = config.yAxis.smallestRightAxisMax
32
+ let smallestRightAxisMax: number | null = null
33
+
34
+ if (smallestRightAxisMaxRaw !== null && smallestRightAxisMaxRaw !== undefined && smallestRightAxisMaxRaw !== '') {
35
+ const coercedSmallestRightAxisMax = Number(smallestRightAxisMaxRaw)
36
+ if (!Number.isNaN(coercedSmallestRightAxisMax)) {
37
+ smallestRightAxisMax = coercedSmallestRightAxisMax
38
+ }
39
+ }
40
+
41
+ if (smallestRightAxisMax !== null && max < smallestRightAxisMax) {
42
+ max = smallestRightAxisMax
43
+ }
30
44
  // if there is a bar series & the right axis doesn't include a negative number, default to zero
31
45
  const hasBarSeries = config.runtime?.barSeriesKeys?.length > 0
32
46
  const hasLineSeries = config.runtime?.lineSeriesKeys?.length > 0
@@ -9,6 +9,7 @@ import {
9
9
  getTicks
10
10
  } from '@visx/scale'
11
11
  import { useContext } from 'react'
12
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
12
13
  import ConfigContext from '../ConfigContext'
13
14
  import { ChartConfig } from '../types/ChartConfig'
14
15
  import { ChartContext } from '../types/ChartContext'
@@ -59,9 +60,8 @@ const useScales = (properties: useScaleProps) => {
59
60
  } = properties
60
61
 
61
62
  const context = useContext<ChartContext>(ConfigContext)
62
- const { rawData, dimensions, convertLineToBarGraph = false } = context
63
+ const { convertLineToBarGraph = false } = context
63
64
 
64
- const [screenWidth] = dimensions
65
65
  const isHorizontal = config.orientation === 'horizontal'
66
66
  const { visualizationType, xAxis, forestPlot, runtime } = config
67
67
  const isForestPlot = visualizationType === 'Forest Plot'
@@ -308,65 +308,38 @@ const useScales = (properties: useScaleProps) => {
308
308
  }
309
309
 
310
310
  yScale = scaleLinear({
311
- domain: [0, rawData.length],
311
+ domain: [0, data.length],
312
312
  range: resolvedYRange()
313
313
  })
314
314
 
315
315
  const xAxisPadding = 5
316
+ const [plotStart, plotEnd] = getForestPlotRange(config, data as Record<string, any>[], xMax)
317
+
318
+ if (forestPlot.type === 'Linear') {
319
+ xScale = scaleLinear<LinearScaleConfig>({
320
+ domain: [
321
+ Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
322
+ Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
323
+ ],
324
+ range: [plotStart, plotEnd],
325
+ type: scaleTypes.LINEAR
326
+ })
327
+ xScale.type = scaleTypes.LINEAR
328
+ }
316
329
 
317
- const leftWidthOffset = (Number(forestPlot.leftWidthOffset) / 100) * xMax
318
- const rightWidthOffset = (Number(forestPlot.rightWidthOffset) / 100) * xMax
319
-
320
- const rightWidthOffsetMobile = (Number(forestPlot.rightWidthOffsetMobile) / 100) * xMax
321
- const leftWidthOffsetMobile = (Number(forestPlot.leftWidthOffsetMobile) / 100) * xMax
322
-
323
- if (screenWidth > 480) {
324
- if (forestPlot.type === 'Linear') {
325
- xScale = scaleLinear({
326
- domain: [
327
- Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
328
- Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
329
- ],
330
- range: [leftWidthOffset, Number(screenWidth) - rightWidthOffset]
331
- })
332
- xScale.type = scaleTypes.LINEAR
333
- }
334
- if (forestPlot.type === 'Logarithmic') {
335
- let max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
336
- let fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
337
-
338
- xScale = scaleLog<LogScaleConfig>({
339
- domain: [fp_min, max],
340
- range: [leftWidthOffset, xMax - rightWidthOffset],
341
- nice: true
342
- })
343
- xScale.type = scaleTypes.LOG
344
- }
345
- } else {
346
- if (forestPlot.type === 'Linear') {
347
- xScale = scaleLinear<LinearScaleConfig>({
348
- domain: [
349
- Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
350
- Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
351
- ],
352
- range: [leftWidthOffsetMobile, xMax - rightWidthOffsetMobile],
353
- type: scaleTypes.LINEAR
354
- })
355
- }
356
-
357
- if (forestPlot.type === 'Logarithmic') {
358
- let max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
359
- let fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
360
-
361
- xScale = scaleLog<LogScaleConfig>({
362
- domain: [fp_min, max],
363
- range: [leftWidthOffset, xMax - rightWidthOffset],
364
- nice: true,
365
- base: max > 1 ? 10 : 2,
366
- round: false,
367
- type: scaleTypes.LOG
368
- })
369
- }
330
+ if (forestPlot.type === 'Logarithmic') {
331
+ const max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
332
+ const fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
333
+
334
+ xScale = scaleLog<LogScaleConfig>({
335
+ domain: [fp_min, max],
336
+ range: [plotStart, plotEnd],
337
+ nice: true,
338
+ base: max > 1 ? 10 : 2,
339
+ round: false,
340
+ type: scaleTypes.LOG
341
+ })
342
+ xScale.type = scaleTypes.LOG
370
343
  }
371
344
  }
372
345
  return {
@@ -525,3 +498,66 @@ const sortXAxisData = (xAxisData, sortByRecentDate) => {
525
498
  return xAxisData.sort((a, b) => Number(a) - Number(b))
526
499
  }
527
500
  }
501
+
502
+ const FOREST_PLOT_FONT = 'normal 12px Nunito, sans-serif'
503
+ const FOREST_PLOT_GAP = 24
504
+ const FOREST_PLOT_MIN_WIDTH = 120
505
+ const FOREST_PLOT_MAX_LEFT_RATIO = 0.45
506
+ const FOREST_PLOT_MAX_RIGHT_RATIO = 0.35
507
+
508
+ const getForestPlotRange = (config: ChartConfig, data: Record<string, any>[], xMax: number): [number, number] => {
509
+ if (!xMax) return [0, 0]
510
+
511
+ const leftReserve = getForestPlotLeftReserve(config, data, xMax)
512
+ const rightReserve = getForestPlotRightReserve(config, data, xMax)
513
+ const availableReserve = Math.max(xMax - FOREST_PLOT_MIN_WIDTH, 0)
514
+ const totalReserve = leftReserve + rightReserve
515
+
516
+ if (totalReserve <= availableReserve) {
517
+ return [leftReserve, xMax - rightReserve]
518
+ }
519
+
520
+ if (!availableReserve || !totalReserve) {
521
+ return [0, xMax]
522
+ }
523
+
524
+ const reserveScale = availableReserve / totalReserve
525
+ return [leftReserve * reserveScale, xMax - rightReserve * reserveScale]
526
+ }
527
+
528
+ const getForestPlotLeftReserve = (config: ChartConfig, data: Record<string, any>[], xMax: number) => {
529
+ const { forestPlot, xAxis } = config
530
+ const columns = Object.values(config.columns || {}) as Record<string, any>[]
531
+ const studyTextWidth = forestPlot.hideDateCategoryCol
532
+ ? 0
533
+ : getForestPlotTextWidth([xAxis.dataKey, ...data.map(row => row?.[xAxis.dataKey])])
534
+
535
+ const leftColumnExtent = columns
536
+ .filter(column => column?.forestPlot && !column?.forestPlotAlignRight)
537
+ .reduce((maxExtent, column) => {
538
+ const columnStart = Number(column.forestPlotStartingPoint ?? column.startingPoint ?? 0)
539
+ const columnWidth = getForestPlotTextWidth([column.label, ...data.map(row => row?.[column.name])])
540
+ return Math.max(maxExtent, columnStart + columnWidth)
541
+ }, 0)
542
+
543
+ const reserve = Math.max(studyTextWidth, leftColumnExtent)
544
+ return reserve ? Math.min(reserve + FOREST_PLOT_GAP, xMax * FOREST_PLOT_MAX_LEFT_RATIO) : 0
545
+ }
546
+
547
+ const getForestPlotRightReserve = (config: ChartConfig, data: Record<string, any>[], xMax: number) => {
548
+ const columns = Object.values(config.columns || {}) as Record<string, any>[]
549
+ const rightColumnWidth = columns
550
+ .filter(column => column?.forestPlot && column?.forestPlotAlignRight)
551
+ .reduce((maxWidth, column) => {
552
+ const columnWidth = getForestPlotTextWidth([column.label, ...data.map(row => row?.[column.name])])
553
+ return Math.max(maxWidth, columnWidth)
554
+ }, 0)
555
+
556
+ return rightColumnWidth ? Math.min(rightColumnWidth + FOREST_PLOT_GAP, xMax * FOREST_PLOT_MAX_RIGHT_RATIO) : 0
557
+ }
558
+
559
+ const getForestPlotTextWidth = (values: unknown[]) =>
560
+ values.reduce((maxWidth, value) => {
561
+ const text = value === null || value === undefined ? '' : String(value)
562
+ return Math.max(maxWidth, getTextWidth(text, FOREST_PLOT_FONT) || 0)
563
+ }, 0)
@@ -13,6 +13,7 @@ import ConfigContext from '../ConfigContext'
13
13
  export const useSmallMultipleSynchronization = (
14
14
  xMax: number,
15
15
  yMax: number,
16
+ yAxisWidth: number,
16
17
  getXValueFromCoordinate: (x: number) => any
17
18
  ) => {
18
19
  const { config, handleSmallMultipleHover } = useContext(ConfigContext)
@@ -25,8 +26,6 @@ export const useSmallMultipleSynchronization = (
25
26
  }
26
27
  }
27
28
 
28
- const yAxisSize = Number(config.yAxis.size || 0)
29
-
30
29
  const onMouseMove = (event: any) => {
31
30
  const svgRect = event.currentTarget.getBoundingClientRect()
32
31
  const x = event.clientX - svgRect.left
@@ -34,10 +33,10 @@ export const useSmallMultipleSynchronization = (
34
33
 
35
34
  // Only trigger synchronized tooltips when mouse is over the valid chart area
36
35
  // (to the right of the Y-axis and within chart bounds)
37
- const isOverChartArea = x >= yAxisSize && x <= yAxisSize + xMax && y >= 0 && y <= yMax
36
+ const isOverChartArea = x >= yAxisWidth && x <= yAxisWidth + xMax && y >= 0 && y <= yMax
38
37
 
39
38
  if (isOverChartArea) {
40
- const xAxisValue = getXValueFromCoordinate(x - yAxisSize)
39
+ const xAxisValue = getXValueFromCoordinate(x - yAxisWidth)
41
40
  if (xAxisValue !== null && xAxisValue !== undefined) {
42
41
  handleSmallMultipleHover(xAxisValue, y)
43
42
  return