@cdc/chart 4.26.3 → 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 (128) hide show
  1. package/CONFIG.md +169 -0
  2. package/README.md +42 -17
  3. package/dist/cdcchart.js +34725 -33730
  4. package/examples/feature/bar/additional-column-tooltip.json +3 -3
  5. package/examples/feature/deviation/planet-deviation-config.json +2 -2
  6. package/examples/feature/paired-bar/paired-bar-example.json +2 -2
  7. package/examples/feature/tests-case-rate/case-rate-example-config.json +2 -2
  8. package/examples/feature/tests-covid/covid-confidence-example-config.json +2 -2
  9. package/examples/feature/tests-covid/covid-example-config.json +2 -2
  10. package/examples/feature/tests-cutoff/cutoff-example-config.json +2 -2
  11. package/examples/feature/tests-non-numerics/planet-pie-example-config-nonnumeric.json +2 -2
  12. package/examples/metadata-variables.json +1 -1
  13. package/examples/minimal-example.json +24 -0
  14. package/examples/private/test2.json +35012 -0
  15. package/package.json +4 -4
  16. package/src/CdcChartComponent.tsx +9 -6
  17. package/src/_stories/{Chart.HTMLInDataTable.stories.tsx → Chart.HTMLInDataTable.smoke.stories.tsx} +10 -0
  18. package/src/_stories/Chart.Minimal.smoke.stories.tsx +25 -0
  19. package/src/_stories/{Chart.SmallMultiples.stories.tsx → Chart.SmallMultiples.smoke.stories.tsx} +17 -1
  20. package/src/_stories/{Chart.stories.tsx → Chart.smoke.stories.tsx} +12 -0
  21. package/src/_stories/{Chart.tooltip.stories.tsx → Chart.tooltip.smoke.stories.tsx} +68 -0
  22. package/src/_stories/ChartBar.Editor.stories.tsx +88 -85
  23. package/src/_stories/ChartEditor.Editor.stories.tsx +23 -0
  24. package/src/_stories/ChartLine.DashedDates.smoke.stories.tsx +23 -0
  25. package/src/_stories/_mock/line_chart_dashed_dates.json +276 -0
  26. package/src/_stories/_mock/paired-bar-abbr.json +1 -1
  27. package/src/_stories/_mock/paired-bar.json +1 -1
  28. package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
  29. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -2
  30. package/src/components/Axis/BottomAxis.tsx +1 -1
  31. package/src/components/Axis/LeftAxis.tsx +2 -4
  32. package/src/components/Axis/LeftAxisGridlines.tsx +1 -3
  33. package/src/components/BarChart/components/BarChart.Horizontal.tsx +9 -5
  34. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +9 -5
  35. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +9 -5
  36. package/src/components/BarChart/components/BarChart.Vertical.tsx +9 -6
  37. package/src/components/BarChart/components/BarChart.tsx +2 -6
  38. package/src/components/BarChart/helpers/useBarChart.ts +1 -1
  39. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +3 -3
  40. package/src/components/EditorPanel/EditorPanel.tsx +633 -908
  41. package/src/components/EditorPanel/EditorPanelContext.ts +1 -1
  42. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +216 -165
  43. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +168 -116
  44. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +154 -109
  45. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +3 -3
  46. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +12 -26
  47. package/src/components/EditorPanel/useEditorPermissions.ts +1 -10
  48. package/src/components/Forecasting/Forecasting.tsx +2 -2
  49. package/src/components/HorizonChart/HorizonChart.tsx +10 -2
  50. package/src/components/HoverLine/HoverLine.tsx +6 -8
  51. package/src/components/Legend/Legend.Component.tsx +4 -4
  52. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +5 -0
  53. package/src/components/Legend/LegendGroup/LegendGroup.tsx +3 -1
  54. package/src/components/Legend/helpers/index.ts +0 -2
  55. package/src/components/LineChart/LineChartProps.ts +1 -0
  56. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +3 -2
  57. package/src/components/LineChart/index.tsx +11 -4
  58. package/src/components/LinearChart/VisualizationRenderer.tsx +11 -1
  59. package/src/components/LinearChart/tests/LinearChart.test.tsx +147 -0
  60. package/src/components/LinearChart.tsx +98 -41
  61. package/src/components/PairedBarChart.jsx +2 -2
  62. package/src/components/PieChart/PieChart.tsx +11 -9
  63. package/src/components/Regions/components/Regions.tsx +30 -20
  64. package/src/components/ScatterPlot/ScatterPlot.jsx +19 -17
  65. package/src/components/SmallMultiples/SmallMultipleTile.tsx +1 -0
  66. package/src/components/Sparkline/components/SparkLine.tsx +1 -1
  67. package/src/components/WarmingStripes/WarmingStripes.tsx +5 -3
  68. package/src/data/initial-state.js +1 -0
  69. package/src/helpers/calculateLeftYAxisWidth.ts +193 -0
  70. package/src/helpers/tests/tooltipHelpers.test.ts +151 -0
  71. package/src/helpers/tooltipHelpers.ts +176 -0
  72. package/src/hooks/useHighlightedBars.ts +9 -16
  73. package/src/hooks/useProgrammaticTooltip.ts +16 -2
  74. package/src/hooks/useSmallMultipleSynchronization.ts +3 -4
  75. package/src/hooks/useTooltip.tsx +167 -38
  76. package/src/test/CdcChart.test.jsx +24 -0
  77. package/src/types/ChartConfig.ts +1 -0
  78. package/LICENSE +0 -201
  79. package/examples/chart-regression-2.json +0 -2360
  80. package/examples/feature/__data__/age-adjusted-rates.json +0 -1486
  81. package/examples/feature/__data__/city-temperature.json +0 -2198
  82. package/examples/feature/bar/Barchart_with_negative.json +0 -34
  83. package/examples/feature/boxplot/box-plot-data.json +0 -71
  84. package/examples/feature/boxplot/boxplot-data.json +0 -156
  85. package/examples/feature/combo/combochart-categories_are_numbers .json +0 -18
  86. package/examples/feature/forest-plot/log.json +0 -26
  87. package/examples/feature/tests-date-exclusions/date-exclusions-data.json +0 -162
  88. package/examples/region-issue.json +0 -2065
  89. package/examples/xaxis.json +0 -493
  90. /package/examples/{data → __data__}/data-with-metadata.json +0 -0
  91. /package/examples/feature/bar/{tall-data.json → __data__/tall-data.json} +0 -0
  92. /package/examples/feature/deviation/{planet-deviation-data.json → __data__/planet-deviation-data.json} +0 -0
  93. /package/examples/feature/paired-bar/{paired-bar-data.json → __data__/paired-bar-data.json} +0 -0
  94. /package/examples/feature/paired-bar/{paired-bar-formatted.json → __data__/paired-bar-formatted.json} +0 -0
  95. /package/examples/feature/tests-case-rate/{case-rate-example-data.json → __data__/case-rate-example-data.json} +0 -0
  96. /package/examples/feature/tests-covid/{covid-example-data-confidence.json → __data__/covid-example-data-confidence.json} +0 -0
  97. /package/examples/feature/tests-covid/{covid-example-data.json → __data__/covid-example-data.json} +0 -0
  98. /package/examples/feature/tests-cutoff/{cutoff-example-data.json → __data__/cutoff-example-data.json} +0 -0
  99. /package/examples/feature/tests-non-numerics/{planet-example-data-nonnumeric.json → __data__/planet-example-data-nonnumeric.json} +0 -0
  100. /package/src/_stories/{Chart.Anchors.stories.tsx → Chart.Anchors.smoke.stories.tsx} +0 -0
  101. /package/src/_stories/{Chart.BoxPlot.stories.tsx → Chart.BoxPlot.smoke.stories.tsx} +0 -0
  102. /package/src/_stories/{Chart.CI.stories.tsx → Chart.CI.smoke.stories.tsx} +0 -0
  103. /package/src/_stories/{Chart.Combo.stories.tsx → Chart.Combo.smoke.stories.tsx} +0 -0
  104. /package/src/_stories/{Chart.CustomColors.stories.tsx → Chart.CustomColors.smoke.stories.tsx} +0 -0
  105. /package/src/_stories/{Chart.Defaults.stories.tsx → Chart.Defaults.smoke.stories.tsx} +0 -0
  106. /package/src/_stories/{Chart.DynamicSeries.stories.tsx → Chart.DynamicSeries.smoke.stories.tsx} +0 -0
  107. /package/src/_stories/{Chart.Filters.stories.tsx → Chart.Filters.smoke.stories.tsx} +0 -0
  108. /package/src/_stories/{Chart.Forecast.stories.tsx → Chart.Forecast.smoke.stories.tsx} +0 -0
  109. /package/src/_stories/{Chart.Legend.Gradient.stories.tsx → Chart.Legend.Gradient.smoke.stories.tsx} +0 -0
  110. /package/src/_stories/{Chart.Patterns.stories.tsx → Chart.Patterns.smoke.stories.tsx} +0 -0
  111. /package/src/_stories/{Chart.PreserveDecimals.stories.tsx → Chart.PreserveDecimals.smoke.stories.tsx} +0 -0
  112. /package/src/_stories/{Chart.Regions.Categorical.stories.tsx → Chart.Regions.Categorical.smoke.stories.tsx} +0 -0
  113. /package/src/_stories/{Chart.Regions.DateScale.stories.tsx → Chart.Regions.DateScale.smoke.stories.tsx} +0 -0
  114. /package/src/_stories/{Chart.Regions.DateTimeScale.stories.tsx → Chart.Regions.DateTimeScale.smoke.stories.tsx} +0 -0
  115. /package/src/_stories/{Chart.ScatterPlot.stories.tsx → Chart.ScatterPlot.smoke.stories.tsx} +0 -0
  116. /package/src/_stories/{Chart.SmallestLeftAxisMax.stories.tsx → Chart.SmallestLeftAxisMax.smoke.stories.tsx} +0 -0
  117. /package/src/_stories/{ChartAnnotation.stories.tsx → ChartAnnotation.smoke.stories.tsx} +0 -0
  118. /package/src/_stories/{ChartAxisLabels.stories.tsx → ChartAxisLabels.smoke.stories.tsx} +0 -0
  119. /package/src/_stories/{ChartAxisTitles.stories.tsx → ChartAxisTitles.smoke.stories.tsx} +0 -0
  120. /package/src/_stories/{ChartBrush.Matrix.Continuous.stories.tsx → ChartBrush.Matrix.Continuous.smoke.stories.tsx} +0 -0
  121. /package/src/_stories/{ChartBrush.Matrix.Date.stories.tsx → ChartBrush.Matrix.Date.smoke.stories.tsx} +0 -0
  122. /package/src/_stories/{ChartBrush.Matrix.DateTime.stories.tsx → ChartBrush.Matrix.DateTime.smoke.stories.tsx} +0 -0
  123. /package/src/_stories/{ChartBrush.stories.tsx → ChartBrush.smoke.stories.tsx} +0 -0
  124. /package/src/_stories/{ChartLine.QuadrantAngles.stories.tsx → ChartLine.QuadrantAngles.smoke.stories.tsx} +0 -0
  125. /package/src/_stories/{ChartLine.Suppression.stories.tsx → ChartLine.Suppression.smoke.stories.tsx} +0 -0
  126. /package/src/_stories/{ChartLine.Symbols.stories.tsx → ChartLine.Symbols.smoke.stories.tsx} +0 -0
  127. /package/src/_stories/{ChartPrefixSuffix.stories.tsx → ChartPrefixSuffix.smoke.stories.tsx} +0 -0
  128. /package/src/_stories/{TechAdoptionWithLinks.stories.tsx → TechAdoptionWithLinks.smoke.stories.tsx} +0 -0
@@ -0,0 +1,193 @@
1
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
2
+ import {
3
+ DEFAULT_TICK_LENGTH,
4
+ TICK_LABEL_MARGIN_RIGHT,
5
+ VALUE_ON_LINE_PADDING_NO_AXIS,
6
+ VALUE_ON_LINE_PADDING_WITH_AXIS
7
+ } from '../components/Axis/axis.constants'
8
+
9
+ type CalculateLeftYAxisWidthProps = {
10
+ config: any
11
+ data: any[]
12
+ yScale: any
13
+ numTicks: number
14
+ parentWidth: number
15
+ tickLabelFont: string
16
+ axisLabelFontSize: number
17
+ handleLeftTickFormatting: (tick: any, index: number, ticks: any[]) => string
18
+ categoryLabelSpace?: number
19
+ horizontalYAxisLabelSpace?: number
20
+ }
21
+
22
+ const AXIS_LABEL_PADDING = 5
23
+ const MIN_LEFT_AXIS_WIDTH = 0
24
+ const MAX_LEFT_AXIS_WIDTH_RATIO = 0.45
25
+
26
+ type TickValueProps = Omit<
27
+ CalculateLeftYAxisWidthProps,
28
+ 'parentWidth' | 'tickLabelFont' | 'axisLabelFontSize' | 'handleLeftTickFormatting'
29
+ >
30
+
31
+ const getTickValues = ({ config, data, yScale, numTicks }: TickValueProps) => {
32
+ if (config.visualizationType === 'Forest Plot') {
33
+ return data.map((_, index) => index)
34
+ }
35
+
36
+ if (typeof yScale?.ticks === 'function') {
37
+ return yScale.ticks(numTicks)
38
+ }
39
+
40
+ if (typeof yScale?.domain === 'function') {
41
+ return yScale.domain()
42
+ }
43
+
44
+ return []
45
+ }
46
+
47
+ const getAxisLabelSpace = (config: any, axisLabelFontSize: number) => {
48
+ return !config.hideYAxisLabel && config.runtime.yAxis.label ? axisLabelFontSize + AXIS_LABEL_PADDING : 0
49
+ }
50
+
51
+ const measureWidestLabel = (labels: string[], tickLabelFont: string) => {
52
+ return labels.length ? Math.max(...labels.map(label => getTextWidth(label, tickLabelFont))) : 0
53
+ }
54
+
55
+ const clampYAxisWidth = (calculatedWidth: number, parentWidth: number) => {
56
+ const maxAllowedWidth = parentWidth * MAX_LEFT_AXIS_WIDTH_RATIO
57
+ return Math.max(MIN_LEFT_AXIS_WIDTH, Math.min(Math.ceil(calculatedWidth), maxAllowedWidth))
58
+ }
59
+
60
+ const formatMeasuredTickLabels = ({
61
+ tickValues,
62
+ handleLeftTickFormatting,
63
+ inlineLabel,
64
+ labelsAboveGridlines
65
+ }: {
66
+ tickValues: any[]
67
+ handleLeftTickFormatting: (tick: any, index: number, ticks: any[]) => string
68
+ inlineLabel?: string
69
+ labelsAboveGridlines?: boolean
70
+ }) => {
71
+ const inlineLabelHasNoSpace = !inlineLabel?.includes(' ')
72
+
73
+ return tickValues.map((tick, index, ticks) => {
74
+ const formatted = handleLeftTickFormatting(tick, index, ticks)
75
+ const isLastTick = index === ticks.length - 1
76
+
77
+ if (isLastTick && inlineLabel && (labelsAboveGridlines || inlineLabelHasNoSpace)) {
78
+ return `${formatted}${inlineLabel}`
79
+ }
80
+
81
+ return String(formatted ?? '')
82
+ })
83
+ }
84
+
85
+ const getHorizonChartWidth = ({
86
+ config,
87
+ parentWidth,
88
+ tickLabelFont,
89
+ axisLabelFontSize
90
+ }: Pick<CalculateLeftYAxisWidthProps, 'config' | 'parentWidth' | 'tickLabelFont' | 'axisLabelFontSize'>) => {
91
+ const seriesKeys =
92
+ config.runtime?.seriesKeys && config.runtime.seriesKeys.length > 0
93
+ ? config.runtime.seriesKeys
94
+ : config.series?.map(series => series.dataKey) || []
95
+ const labels = seriesKeys.map(seriesKey => String(config.runtime?.seriesLabels?.[seriesKey] || seriesKey))
96
+ const labelWidth = config.yAxis.hideLabel ? 0 : measureWidestLabel(labels, tickLabelFont)
97
+ const calculatedWidth =
98
+ labelWidth +
99
+ DEFAULT_TICK_LENGTH +
100
+ TICK_LABEL_MARGIN_RIGHT +
101
+ getAxisLabelSpace(config, axisLabelFontSize) +
102
+ Number(config.yAxis.axisPadding || 0)
103
+
104
+ return clampYAxisWidth(calculatedWidth, parentWidth)
105
+ }
106
+
107
+ const getHorizontalAxisWidth = ({
108
+ config,
109
+ tickLabelWidth,
110
+ categoryLabelSpace,
111
+ horizontalYAxisLabelSpace
112
+ }: Pick<CalculateLeftYAxisWidthProps, 'config' | 'categoryLabelSpace' | 'horizontalYAxisLabelSpace'> & {
113
+ tickLabelWidth: number
114
+ }) => {
115
+ const horizontalTickWidth = tickLabelWidth + DEFAULT_TICK_LENGTH + TICK_LABEL_MARGIN_RIGHT
116
+ return Math.max(horizontalTickWidth + horizontalYAxisLabelSpace, categoryLabelSpace + horizontalYAxisLabelSpace)
117
+ }
118
+
119
+ const getCategoricalAxisWidth = ({
120
+ config,
121
+ baseWidth,
122
+ tickLabelFont
123
+ }: Pick<CalculateLeftYAxisWidthProps, 'config' | 'tickLabelFont'> & {
124
+ baseWidth: number
125
+ }) => {
126
+ if (config.yAxis.type !== 'categorical' || config.orientation !== 'vertical' || config.yAxis.hideLabel) {
127
+ return baseWidth
128
+ }
129
+
130
+ const categoryLabels = (config.yAxis.categories || []).map(category => String(category?.label || ''))
131
+ const categoryLabelWidth = measureWidestLabel(categoryLabels, tickLabelFont)
132
+
133
+ return Math.max(baseWidth, categoryLabelWidth + DEFAULT_TICK_LENGTH + TICK_LABEL_MARGIN_RIGHT)
134
+ }
135
+
136
+ export const calculateLeftYAxisWidth = ({
137
+ config,
138
+ data,
139
+ yScale,
140
+ numTicks,
141
+ parentWidth,
142
+ tickLabelFont,
143
+ axisLabelFontSize,
144
+ handleLeftTickFormatting,
145
+ categoryLabelSpace = 0,
146
+ horizontalYAxisLabelSpace = 0
147
+ }: CalculateLeftYAxisWidthProps): number => {
148
+ if (config.visualizationType === 'Horizon Chart') {
149
+ return getHorizonChartWidth({ config, parentWidth, tickLabelFont, axisLabelFontSize })
150
+ }
151
+
152
+ const tickValues = getTickValues({ config, data, yScale, numTicks })
153
+ const { hideAxis, inlineLabel, labelsAboveGridlines } = config.yAxis
154
+ const formattedTicks = formatMeasuredTickLabels({
155
+ tickValues,
156
+ handleLeftTickFormatting,
157
+ inlineLabel,
158
+ labelsAboveGridlines
159
+ })
160
+ const tickLabelWidth = config.yAxis.hideLabel ? 0 : measureWidestLabel(formattedTicks, tickLabelFont)
161
+
162
+ const valueOnLinePadding = hideAxis
163
+ ? Math.abs(VALUE_ON_LINE_PADDING_NO_AXIS)
164
+ : Math.abs(VALUE_ON_LINE_PADDING_WITH_AXIS)
165
+ const tickPadding = labelsAboveGridlines ? valueOnLinePadding : TICK_LABEL_MARGIN_RIGHT + DEFAULT_TICK_LENGTH
166
+ const verticalAxisLabelSpace = getAxisLabelSpace(config, axisLabelFontSize)
167
+ const reservedTickLabelWidth = labelsAboveGridlines ? 0 : tickLabelWidth
168
+ const reservedTickPadding = labelsAboveGridlines || config.yAxis.hideLabel ? 0 : tickPadding
169
+
170
+ const defaultCalculatedWidth =
171
+ reservedTickLabelWidth + reservedTickPadding + verticalAxisLabelSpace + Number(config.yAxis.axisPadding || 0)
172
+
173
+ if (config.orientation === 'horizontal') {
174
+ const calculatedWidth = getHorizontalAxisWidth({
175
+ config,
176
+ tickLabelWidth,
177
+ categoryLabelSpace,
178
+ horizontalYAxisLabelSpace
179
+ })
180
+
181
+ return clampYAxisWidth(calculatedWidth, parentWidth)
182
+ }
183
+
184
+ const calculatedWidth = getCategoricalAxisWidth({
185
+ config,
186
+ baseWidth: defaultCalculatedWidth,
187
+ tickLabelFont
188
+ })
189
+
190
+ return clampYAxisWidth(calculatedWidth, parentWidth)
191
+ }
192
+
193
+ export default calculateLeftYAxisWidth
@@ -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,
@@ -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