@cdc/chart 4.26.1 → 4.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  4. package/dist/cdcchart.js +54742 -49796
  5. package/examples/data/data-with-metadata.json +10 -0
  6. package/examples/default.json +378 -0
  7. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  8. package/examples/feature/annotations/index.json +3 -6
  9. package/examples/feature/horizon/horizon-chart.json +395 -0
  10. package/examples/feature/pie/planet-pie-example-config.json +2 -1
  11. package/examples/line-chart-states.json +1085 -0
  12. package/examples/metadata-variables.json +58 -0
  13. package/examples/private/123.json +694 -0
  14. package/examples/private/anchor-issue.json +4094 -0
  15. package/examples/private/backwards-slider.json +10430 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/timeline-data.json +1 -0
  18. package/examples/private/timeline.json +389 -0
  19. package/examples/radar-chart-simple.json +133 -0
  20. package/examples/radar-chart.json +148 -0
  21. package/index.html +1 -31
  22. package/package.json +57 -59
  23. package/src/CdcChart.tsx +8 -4
  24. package/src/CdcChartComponent.tsx +398 -284
  25. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  26. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  27. package/src/_stories/Chart.CI.stories.tsx +13 -0
  28. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  29. package/src/_stories/Chart.CustomColors.stories.tsx +78 -0
  30. package/src/_stories/Chart.Defaults.stories.tsx +95 -0
  31. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  32. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  33. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  34. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  35. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  36. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  37. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  38. package/src/_stories/Chart.Regions.Categorical.stories.tsx +13 -0
  39. package/src/_stories/Chart.Regions.DateScale.stories.tsx +19 -0
  40. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +25 -10
  41. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  42. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  43. package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
  44. package/src/_stories/Chart.stories.tsx +72 -1
  45. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  46. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  47. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  48. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  49. package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
  50. package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
  51. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  52. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  53. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  54. package/src/_stories/ChartBrush.stories.tsx +7 -0
  55. package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
  56. package/src/_stories/ChartEditor.stories.tsx +7 -0
  57. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  58. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  59. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  60. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  61. package/src/_stories/TechAdoptionWithLinks.stories.tsx +7 -0
  62. package/src/_stories/_mock/brush_continuous.json +86 -0
  63. package/src/_stories/_mock/brush_date_large.json +176 -0
  64. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  65. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  66. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  67. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  68. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  69. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  70. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  71. package/src/_stories/_mock/paired-bar-abbr.json +421 -0
  72. package/src/_stories/_mock/pie_custom_colors.json +268 -0
  73. package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
  74. package/src/components/Annotations/components/AnnotationDraggable.styles.css +14 -20
  75. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  76. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  77. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  78. package/src/components/Annotations/components/AnnotationList.styles.css +12 -18
  79. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  80. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  81. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  82. package/src/components/Axis/BottomAxis.tsx +277 -0
  83. package/src/components/Axis/LeftAxis.tsx +404 -0
  84. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  85. package/src/components/Axis/PairedBarAxis.tsx +192 -0
  86. package/src/components/Axis/README.md +94 -0
  87. package/src/components/Axis/RightAxis.tsx +108 -0
  88. package/src/components/Axis/axis.constants.ts +21 -0
  89. package/src/components/Axis/index.ts +7 -0
  90. package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
  91. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
  92. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
  93. package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
  94. package/src/components/BarChart/components/BarChart.tsx +7 -1
  95. package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
  96. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
  97. package/src/components/BarChart/helpers/useBarChart.ts +3 -0
  98. package/src/components/Brush/BrushSelector.tsx +155 -22
  99. package/src/components/Brush/MiniChartPreview.tsx +133 -21
  100. package/src/components/EditorPanel/EditorPanel.tsx +81 -54
  101. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +67 -29
  102. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
  103. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +120 -2
  104. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +25 -43
  105. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  106. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -3
  107. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +66 -43
  108. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  109. package/src/components/EditorPanel/editor-panel.scss +1 -1
  110. package/src/components/EditorPanel/useEditorPermissions.ts +55 -26
  111. package/src/components/ForestPlot/ForestPlot.tsx +26 -22
  112. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  113. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  114. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  115. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  116. package/src/components/HorizonChart/index.tsx +3 -0
  117. package/src/components/Legend/Legend.Component.tsx +52 -4
  118. package/src/components/Legend/Legend.tsx +1 -1
  119. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
  120. package/src/components/Legend/LegendValueRange.tsx +77 -0
  121. package/src/components/Legend/helpers/createFormatLabels.tsx +16 -2
  122. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  123. package/src/components/LineChart/helpers/README.md +292 -0
  124. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  125. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  126. package/src/components/LineChart/index.tsx +44 -8
  127. package/src/components/LinearChart/README.md +109 -0
  128. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  129. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  130. package/src/components/LinearChart/tests/LinearChart.test.tsx +278 -0
  131. package/src/components/LinearChart/tests/mockConfigContext.ts +131 -0
  132. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  133. package/src/components/LinearChart.tsx +268 -1057
  134. package/src/components/PieChart/PieChart.tsx +20 -5
  135. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  136. package/src/components/RadarChart/RadarChart.tsx +298 -0
  137. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  138. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  139. package/src/components/RadarChart/helpers.ts +83 -0
  140. package/src/components/RadarChart/index.tsx +3 -0
  141. package/src/components/Regions/components/Regions.tsx +6 -6
  142. package/src/components/Sankey/components/Sankey.tsx +3 -3
  143. package/src/components/Sankey/sankey.scss +1 -1
  144. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  145. package/src/components/Sparkline/index.scss +4 -2
  146. package/src/components/WarmingStripes/WarmingStripes.tsx +95 -25
  147. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
  148. package/src/data/initial-state.js +37 -15
  149. package/src/data/legacy-defaults.ts +18 -0
  150. package/src/helpers/abbreviateNumber.ts +24 -17
  151. package/src/helpers/getChartPatternId.ts +17 -0
  152. package/src/helpers/getExcludedData.ts +4 -0
  153. package/src/helpers/getMinMax.ts +16 -2
  154. package/src/helpers/handleChartAriaLabels.ts +19 -19
  155. package/src/helpers/handleLineType.ts +22 -18
  156. package/src/helpers/seriesColumnSettings.ts +114 -0
  157. package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
  158. package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
  159. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  160. package/src/hooks/useRightAxis.ts +14 -0
  161. package/src/hooks/useScales.ts +99 -56
  162. package/src/hooks/useTooltip.tsx +23 -3
  163. package/src/scss/main.scss +157 -79
  164. package/src/selectors/README.md +68 -0
  165. package/src/store/chart.reducer.ts +2 -0
  166. package/src/test/CdcChart.test.jsx +2 -2
  167. package/src/types/ChartConfig.ts +22 -0
  168. package/src/types/ChartContext.ts +1 -0
  169. package/src/types/Horizon.ts +64 -0
  170. package/tests/fixtures/chart-config-with-metadata.json +29 -0
  171. package/tests/fixtures/data-with-metadata.json +10 -0
  172. package/preview.html +0 -1616
  173. package/src/components/Annotations/components/helpers/index.tsx +0 -46
@@ -1,18 +1,22 @@
1
- export const handleLineType = lineType => {
2
- switch (lineType) {
3
- case 'dashed-sm':
4
- return '5 5'
5
- case 'Dashed Small':
6
- return '5 5'
7
- case 'dashed-md':
8
- return '10 5'
9
- case 'Dashed Medium':
10
- return '10 5'
11
- case 'dashed-lg':
12
- return '15 5'
13
- case 'Dashed Large':
14
- return '15 5'
15
- default:
16
- return 0
17
- }
18
- }
1
+ const DASH_PATTERNS = {
2
+ SMALL: '5 5',
3
+ MEDIUM: '10 5',
4
+ LARGE: '15 5',
5
+ SOLID: 0
6
+ } as const
7
+
8
+ export const handleLineType = (lineType: string): string | number => {
9
+ switch (lineType) {
10
+ case 'dashed-sm':
11
+ case 'Dashed Small':
12
+ return DASH_PATTERNS.SMALL
13
+ case 'dashed-md':
14
+ case 'Dashed Medium':
15
+ return DASH_PATTERNS.MEDIUM
16
+ case 'dashed-lg':
17
+ case 'Dashed Large':
18
+ return DASH_PATTERNS.LARGE
19
+ default:
20
+ return DASH_PATTERNS.SOLID
21
+ }
22
+ }
@@ -0,0 +1,114 @@
1
+ import { Column } from '@cdc/core/types/Column'
2
+ import { Series } from '@cdc/core/types/Series'
3
+
4
+ type ChartColumns = Record<string, Partial<Column>>
5
+ type SeriesItem = Series[number]
6
+ type ColumnFormattingParams = {
7
+ addColPrefix?: string
8
+ addColSuffix?: string
9
+ addColRoundTo?: number
10
+ addColCommas?: boolean
11
+ }
12
+
13
+ const hasOwn = (object: object, key: keyof Column) => Object.prototype.hasOwnProperty.call(object, key)
14
+
15
+ export const createDefaultSeriesColumnConfig = (columnName: string): Column => ({
16
+ name: columnName,
17
+ label: columnName,
18
+ prefix: '',
19
+ suffix: '',
20
+ roundToPlace: 0,
21
+ commas: false,
22
+ dataTable: true,
23
+ order: undefined,
24
+ showInViz: false,
25
+ startingPoint: '0',
26
+ series: undefined,
27
+ tooltips: false,
28
+ forestPlot: false,
29
+ forestPlotAlignRight: false,
30
+ forestPlotStartingPoint: 0
31
+ })
32
+
33
+ export const getSeriesOwnedColumnNames = (series: Partial<SeriesItem>[] = []): string[] => {
34
+ return series.map(item => item?.dataKey).filter(Boolean)
35
+ }
36
+
37
+ export const findColumnConfigByName = (
38
+ columns: ChartColumns = {},
39
+ columnName: string
40
+ ): { columnKey: string; columnConfig: Partial<Column> } | null => {
41
+ for (const [columnKey, columnConfig] of Object.entries(columns)) {
42
+ if (columnConfig?.name === columnName || (!columnConfig?.name && columnKey === columnName)) {
43
+ return { columnKey, columnConfig }
44
+ }
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ export const getSeriesColumnConfig = (columns: ChartColumns = {}, seriesKey: string) => {
51
+ const existingEntry = findColumnConfigByName(columns, seriesKey)
52
+ const baseColumnConfig = createDefaultSeriesColumnConfig(seriesKey)
53
+
54
+ return {
55
+ columnKey: existingEntry?.columnKey || seriesKey,
56
+ columnConfig: {
57
+ ...baseColumnConfig,
58
+ ...(existingEntry?.columnConfig || {}),
59
+ name: seriesKey,
60
+ label: existingEntry?.columnConfig?.label ?? baseColumnConfig.label
61
+ }
62
+ }
63
+ }
64
+
65
+ export const upsertSeriesColumnConfig = (
66
+ columns: ChartColumns = {},
67
+ seriesKey: string,
68
+ updates: Partial<Column>
69
+ ): ChartColumns => {
70
+ const existingEntry = findColumnConfigByName(columns, seriesKey)
71
+ const columnKey = existingEntry?.columnKey || seriesKey
72
+ const nextColumnConfig = {
73
+ ...(existingEntry?.columnConfig || {}),
74
+ ...updates,
75
+ name: seriesKey
76
+ }
77
+
78
+ if (
79
+ nextColumnConfig.label === undefined &&
80
+ !hasOwn(existingEntry?.columnConfig || {}, 'label') &&
81
+ !hasOwn(updates, 'label')
82
+ ) {
83
+ delete nextColumnConfig.label
84
+ }
85
+
86
+ return {
87
+ ...columns,
88
+ [columnKey]: nextColumnConfig
89
+ }
90
+ }
91
+
92
+ export const getSeriesColumnFormattingParams = (columnConfig?: Partial<Column>): ColumnFormattingParams | undefined => {
93
+ if (!columnConfig) return undefined
94
+
95
+ const formattingParams: ColumnFormattingParams = {}
96
+
97
+ if (hasOwn(columnConfig, 'prefix')) {
98
+ formattingParams.addColPrefix = columnConfig.prefix ?? ''
99
+ }
100
+
101
+ if (hasOwn(columnConfig, 'suffix')) {
102
+ formattingParams.addColSuffix = columnConfig.suffix ?? ''
103
+ }
104
+
105
+ if (hasOwn(columnConfig, 'roundToPlace')) {
106
+ formattingParams.addColRoundTo = columnConfig.roundToPlace ?? 0
107
+ }
108
+
109
+ if (hasOwn(columnConfig, 'commas')) {
110
+ formattingParams.addColCommas = columnConfig.commas ?? false
111
+ }
112
+
113
+ return Object.keys(formattingParams).length ? formattingParams : undefined
114
+ }
@@ -0,0 +1,77 @@
1
+ import { countNumOfTicks } from '../countNumOfTicks'
2
+ import { expect, describe, it } from 'vitest'
3
+
4
+ const baseArgs = {
5
+ max: 100,
6
+ min: 0,
7
+ data: [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }, { a: 5 }],
8
+ config: { visualizationType: 'Bar' } as any
9
+ }
10
+
11
+ describe('countNumOfTicks', () => {
12
+ it('uses viewport-specific tick count when viewportNumTicks[currentViewport] is set', () => {
13
+ const result = countNumOfTicks({
14
+ ...baseArgs,
15
+ axis: 'xAxis',
16
+ runtime: { xAxis: { numTicks: 6, viewportNumTicks: { xs: 3, xxs: 2 } } },
17
+ currentViewport: 'xs',
18
+ isHorizontal: false
19
+ })
20
+ expect(result).toBe(3)
21
+ })
22
+
23
+ it('falls back to numTicks when viewportNumTicks is absent', () => {
24
+ const result = countNumOfTicks({
25
+ ...baseArgs,
26
+ axis: 'xAxis',
27
+ runtime: { xAxis: { numTicks: 6 } },
28
+ currentViewport: 'xs',
29
+ isHorizontal: false
30
+ })
31
+ expect(result).toBe(6)
32
+ })
33
+
34
+ it('falls back to numTicks when current viewport has no entry in viewportNumTicks', () => {
35
+ const result = countNumOfTicks({
36
+ ...baseArgs,
37
+ axis: 'xAxis',
38
+ runtime: { xAxis: { numTicks: 6, viewportNumTicks: { xxs: 2 } } },
39
+ currentViewport: 'lg',
40
+ isHorizontal: false
41
+ })
42
+ expect(result).toBe(6)
43
+ })
44
+
45
+ it('xAxis vertical with numTicks: 6 returns 6', () => {
46
+ const result = countNumOfTicks({
47
+ ...baseArgs,
48
+ axis: 'xAxis',
49
+ runtime: { xAxis: { numTicks: 6 } },
50
+ currentViewport: 'lg',
51
+ isHorizontal: false
52
+ })
53
+ expect(result).toBe(6)
54
+ })
55
+
56
+ it('yAxis with numTicks: 4 returns 4', () => {
57
+ const result = countNumOfTicks({
58
+ ...baseArgs,
59
+ axis: 'yAxis',
60
+ runtime: { yAxis: { numTicks: 4 } },
61
+ currentViewport: 'lg',
62
+ isHorizontal: false
63
+ })
64
+ expect(result).toBe(4)
65
+ })
66
+
67
+ it('xAxis horizontal with no numTicks returns 4 (hardcoded fallback)', () => {
68
+ const result = countNumOfTicks({
69
+ ...baseArgs,
70
+ axis: 'xAxis',
71
+ runtime: { xAxis: { numTicks: '' } },
72
+ currentViewport: 'lg',
73
+ isHorizontal: true
74
+ })
75
+ expect(result).toBe(4)
76
+ })
77
+ })
@@ -0,0 +1,84 @@
1
+ import {
2
+ createDefaultSeriesColumnConfig,
3
+ findColumnConfigByName,
4
+ getSeriesColumnConfig,
5
+ getSeriesColumnFormattingParams,
6
+ getSeriesOwnedColumnNames,
7
+ upsertSeriesColumnConfig
8
+ } from '../seriesColumnSettings'
9
+
10
+ describe('seriesColumnSettings', () => {
11
+ it('returns all current series data keys as owned column names', () => {
12
+ expect(getSeriesOwnedColumnNames([{ dataKey: 'cases' }, { dataKey: 'deaths' }, {}])).toEqual(['cases', 'deaths'])
13
+ })
14
+
15
+ it('finds an existing column config by configured name', () => {
16
+ const result = findColumnConfigByName(
17
+ {
18
+ additionalColumn1: { name: 'cases', label: 'Cases', tooltips: true }
19
+ },
20
+ 'cases'
21
+ )
22
+
23
+ expect(result).toEqual({
24
+ columnKey: 'additionalColumn1',
25
+ columnConfig: { name: 'cases', label: 'Cases', tooltips: true }
26
+ })
27
+ })
28
+
29
+ it('returns a default-backed series column config when none exists yet', () => {
30
+ const result = getSeriesColumnConfig({}, 'cases')
31
+
32
+ expect(result.columnKey).toBe('cases')
33
+ expect(result.columnConfig).toEqual(createDefaultSeriesColumnConfig('cases'))
34
+ })
35
+
36
+ it('updates an existing matching column config without changing its key', () => {
37
+ const updatedColumns = upsertSeriesColumnConfig(
38
+ {
39
+ additionalColumn1: { name: 'cases', label: 'Cases', dataTable: false }
40
+ },
41
+ 'cases',
42
+ { prefix: '$', commas: true }
43
+ )
44
+
45
+ expect(updatedColumns).toEqual({
46
+ additionalColumn1: {
47
+ name: 'cases',
48
+ label: 'Cases',
49
+ dataTable: false,
50
+ prefix: '$',
51
+ commas: true
52
+ }
53
+ })
54
+ })
55
+
56
+ it('does not persist display defaults when creating a new series-owned column config', () => {
57
+ expect(upsertSeriesColumnConfig({}, 'cases', { label: 'Cases' })).toEqual({
58
+ cases: {
59
+ name: 'cases',
60
+ label: 'Cases'
61
+ }
62
+ })
63
+ })
64
+
65
+ it('only returns explicit formatting overrides and preserves falsey values', () => {
66
+ expect(
67
+ getSeriesColumnFormattingParams({
68
+ prefix: '',
69
+ suffix: ' units',
70
+ roundToPlace: 0,
71
+ commas: false
72
+ })
73
+ ).toEqual({
74
+ addColPrefix: '',
75
+ addColSuffix: ' units',
76
+ addColRoundTo: 0,
77
+ addColCommas: false
78
+ })
79
+ })
80
+
81
+ it('returns undefined when no formatting overrides were explicitly configured', () => {
82
+ expect(getSeriesColumnFormattingParams({ label: 'Cases' })).toBeUndefined()
83
+ })
84
+ })
@@ -8,6 +8,7 @@ interface UseProgrammaticTooltipProps {
8
8
  setShowHoverLine: (show: boolean) => void
9
9
  handleTooltipMouseOver: (event: MouseEvent, additionalChartData?: any) => void
10
10
  hideTooltip: () => void
11
+ setSynchronizedXValue?: (value: any) => void
11
12
  }
12
13
 
13
14
  /**
@@ -21,7 +22,8 @@ export const useProgrammaticTooltip = ({
21
22
  setPoint,
22
23
  setShowHoverLine,
23
24
  handleTooltipMouseOver,
24
- hideTooltip
25
+ hideTooltip,
26
+ setSynchronizedXValue
25
27
  }: UseProgrammaticTooltipProps) => {
26
28
  // Internal SVG ref for DOM manipulation
27
29
  const internalSvgRef = useRef<SVGSVGElement>(null)
@@ -50,6 +52,15 @@ export const useProgrammaticTooltip = ({
50
52
  * @param {number} yCoordinate - Exact Y coordinate to use
51
53
  */
52
54
  triggerTooltipAtDataValue: (xAxisValue: any, yCoordinate: number) => {
55
+ // Warming Stripes positions rects by index (with data sampling), not via xScale,
56
+ // so synthetic mouse events won't map to the correct data points.
57
+ // Route through synchronizedXValue state instead, which WarmingStripes
58
+ // resolves to the matching stripe and calls showTooltip directly.
59
+ if (config.visualizationType === 'Warming Stripes') {
60
+ setSynchronizedXValue?.(xAxisValue)
61
+ return
62
+ }
63
+
53
64
  const pixelX = getCoordinateFromXValue(xAxisValue)
54
65
  const adjustedX = pixelX + Number(config.yAxis.size || 0)
55
66
 
@@ -86,10 +97,20 @@ export const useProgrammaticTooltip = ({
86
97
  hideTooltip: () => {
87
98
  hideTooltip()
88
99
  setShowHoverLine(false)
100
+ setSynchronizedXValue?.(null)
89
101
  }
90
102
  })
91
103
  },
92
- [getCoordinateFromXValue, config.yAxis.size, setPoint, setShowHoverLine, handleTooltipMouseOver, hideTooltip]
104
+ [
105
+ getCoordinateFromXValue,
106
+ config.yAxis.size,
107
+ config.visualizationType,
108
+ setPoint,
109
+ setShowHoverLine,
110
+ handleTooltipMouseOver,
111
+ hideTooltip,
112
+ setSynchronizedXValue
113
+ ]
93
114
  )
94
115
 
95
116
  return internalSvgRef
@@ -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'
@@ -121,6 +121,11 @@ const useScales = (properties: useScaleProps) => {
121
121
  range: [0, xMax]
122
122
  })
123
123
 
124
+ let yScaleAnnotation = scaleLinear({
125
+ domain: [0, 100],
126
+ range: [0, yMax]
127
+ })
128
+
124
129
  // handle Horizontal bars
125
130
  if (isHorizontal) {
126
131
  xScale = composeXScale({ min: min * 1.03, max, xMax, config })
@@ -303,65 +308,38 @@ const useScales = (properties: useScaleProps) => {
303
308
  }
304
309
 
305
310
  yScale = scaleLinear({
306
- domain: [0, rawData.length],
311
+ domain: [0, data.length],
307
312
  range: resolvedYRange()
308
313
  })
309
314
 
310
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
+ }
311
329
 
312
- const leftWidthOffset = (Number(forestPlot.leftWidthOffset) / 100) * xMax
313
- const rightWidthOffset = (Number(forestPlot.rightWidthOffset) / 100) * xMax
314
-
315
- const rightWidthOffsetMobile = (Number(forestPlot.rightWidthOffsetMobile) / 100) * xMax
316
- const leftWidthOffsetMobile = (Number(forestPlot.leftWidthOffsetMobile) / 100) * xMax
317
-
318
- if (screenWidth > 480) {
319
- if (forestPlot.type === 'Linear') {
320
- xScale = scaleLinear({
321
- domain: [
322
- Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
323
- Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
324
- ],
325
- range: [leftWidthOffset, Number(screenWidth) - rightWidthOffset]
326
- })
327
- xScale.type = scaleTypes.LINEAR
328
- }
329
- if (forestPlot.type === 'Logarithmic') {
330
- let max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
331
- let fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
332
-
333
- xScale = scaleLog<LogScaleConfig>({
334
- domain: [fp_min, max],
335
- range: [leftWidthOffset, xMax - rightWidthOffset],
336
- nice: true
337
- })
338
- xScale.type = scaleTypes.LOG
339
- }
340
- } else {
341
- if (forestPlot.type === 'Linear') {
342
- xScale = scaleLinear<LinearScaleConfig>({
343
- domain: [
344
- Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
345
- Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
346
- ],
347
- range: [leftWidthOffsetMobile, xMax - rightWidthOffsetMobile],
348
- type: scaleTypes.LINEAR
349
- })
350
- }
351
-
352
- if (forestPlot.type === 'Logarithmic') {
353
- let max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
354
- let fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
355
-
356
- xScale = scaleLog<LogScaleConfig>({
357
- domain: [fp_min, max],
358
- range: [leftWidthOffset, xMax - rightWidthOffset],
359
- nice: true,
360
- base: max > 1 ? 10 : 2,
361
- round: false,
362
- type: scaleTypes.LOG
363
- })
364
- }
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
365
343
  }
366
344
  }
367
345
  return {
@@ -372,6 +350,7 @@ const useScales = (properties: useScaleProps) => {
372
350
  g2xScale,
373
351
  xScaleNoPadding,
374
352
  xScaleAnnotation,
353
+ yScaleAnnotation,
375
354
  min,
376
355
  max,
377
356
  leftMax,
@@ -387,6 +366,7 @@ const getFirstDayOfMonth = ms => {
387
366
  }
388
367
 
389
368
  const dateFormatHasMonthButNoDays = dateFormat => {
369
+ if (!dateFormat) return false
390
370
  return (
391
371
  (dateFormat.includes('%b') ||
392
372
  dateFormat.includes('%B') ||
@@ -518,3 +498,66 @@ const sortXAxisData = (xAxisData, sortByRecentDate) => {
518
498
  return xAxisData.sort((a, b) => Number(a) - Number(b))
519
499
  }
520
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)
@@ -10,6 +10,11 @@ import { localPoint } from '@visx/event'
10
10
  import { bisector } from 'd3-array'
11
11
  import _, { get } from 'lodash'
12
12
  import { getHorizontalBarHeights } from '../components/BarChart/helpers/getBarHeights'
13
+ import {
14
+ findColumnConfigByName,
15
+ getSeriesColumnFormattingParams,
16
+ getSeriesOwnedColumnNames
17
+ } from '../helpers/seriesColumnSettings'
13
18
 
14
19
  export const useTooltip = props => {
15
20
  // Track the last X-axis value to prevent duplicate analytics events
@@ -27,6 +32,7 @@ export const useTooltip = props => {
27
32
  } = useContext<ChartContext>(ConfigContext)
28
33
  const { xScale, yScale, seriesScale, showTooltip, hideTooltip, interactionLabel = '' } = props
29
34
  const { xAxis, visualizationType, orientation, yAxis, runtime } = config
35
+ const seriesOwnedColumnNames = getSeriesOwnedColumnNames(config.series)
30
36
 
31
37
  // Track the latest xScale in a ref to prevent stale closures
32
38
  const xScaleRef = useRef(xScale)
@@ -72,8 +78,14 @@ export const useTooltip = props => {
72
78
 
73
79
  const getFormattedValue = (seriesKey, value, config, getAxisPosition) => {
74
80
  // handle case where data is missing
75
- const showMissingDataValue = config.general.showMissingDataLabel && (!value || value === 'null')
76
- const formattedValue = seriesKey === config.xAxis.dataKey ? value : formatNumber(value, getAxisPosition(seriesKey))
81
+ const showMissingDataValue =
82
+ config.general.showMissingDataLabel && (value === null || value === undefined || value === '' || value === 'null')
83
+ const seriesColumnConfig = findColumnConfigByName(config.columns || {}, seriesKey)?.columnConfig
84
+ const formattingParams = getSeriesColumnFormattingParams(seriesColumnConfig)
85
+ const formattedValue =
86
+ seriesKey === config.xAxis.dataKey
87
+ ? value
88
+ : formatColNumber(value, getAxisPosition(seriesKey), true, config, formattingParams)
77
89
 
78
90
  return showMissingDataValue ? 'N/A' : formattedValue
79
91
  }
@@ -98,6 +110,9 @@ export const useTooltip = props => {
98
110
  const columnsWithTooltips = []
99
111
  const tooltipItems = [] as any[][]
100
112
  for (const [colKey, column] of Object.entries(config.columns)) {
113
+ const columnName = column.name || colKey
114
+ if (seriesOwnedColumnNames.includes(columnName)) continue
115
+
101
116
  const formattingParams = {
102
117
  addColPrefix: column.prefix,
103
118
  addColSuffix: column.suffix,
@@ -592,6 +607,7 @@ export const useTooltip = props => {
592
607
  case 'Line':
593
608
  case 'Area Chart':
594
609
  case 'Pie':
610
+ case 'Horizon Chart':
595
611
  return common
596
612
  case 'Combo':
597
613
  return [...common, ...ciItems]
@@ -600,6 +616,8 @@ export const useTooltip = props => {
600
616
 
601
617
  case 'Bar':
602
618
  return orientation === 'vertical' ? common : [runtime.yAxis.dataKey, ...runtime?.seriesKeys]
619
+ case 'Warming Stripes':
620
+ return common
603
621
  default:
604
622
  throw new Error('No visualization type found in handleTooltipMouseOver')
605
623
  }
@@ -630,7 +648,9 @@ export const useTooltip = props => {
630
648
  */
631
649
  const getSeriesNameFromLabel = originalColumnName => {
632
650
  let series = config.runtime.series.filter(s => s.dataKey === originalColumnName)
633
- if (series[0]?.name) return series[0]?.name
651
+ if (series[0] && series[0].name !== undefined) return series[0]?.name
652
+ const columnConfig = findColumnConfigByName(config.columns || {}, originalColumnName)?.columnConfig
653
+ if (columnConfig?.label !== undefined) return columnConfig.label
634
654
  return originalColumnName
635
655
  }
636
656