@cdc/chart 4.25.11 → 4.26.1

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 (77) hide show
  1. package/dist/cdcchart.js +38898 -40013
  2. package/examples/feature/pie/planet-pie-example-config.json +48 -2
  3. package/examples/private/DEV-12100.json +1303 -0
  4. package/examples/private/cat-y.json +1235 -0
  5. package/examples/private/data-points.json +228 -0
  6. package/examples/private/height.json +3915 -0
  7. package/examples/private/links.json +569 -0
  8. package/examples/private/quadrant.txt +30 -0
  9. package/examples/private/test-forecast.json +5510 -0
  10. package/examples/private/warming-stripe-test.json +2578 -0
  11. package/examples/private/warming-stripes.json +4763 -0
  12. package/examples/tech-adoption-with-links.json +560 -0
  13. package/index.html +15 -20
  14. package/package.json +5 -4
  15. package/preview.html +1616 -0
  16. package/src/CdcChartComponent.tsx +111 -75
  17. package/src/_stories/Chart.Regions.Categorical.stories.tsx +148 -0
  18. package/src/_stories/Chart.Regions.DateScale.stories.tsx +197 -0
  19. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +297 -0
  20. package/src/_stories/Chart.stories.tsx +8 -0
  21. package/src/_stories/ChartBar.Editor.stories.tsx +11 -6
  22. package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
  23. package/src/_stories/ChartBrush.stories.tsx +50 -0
  24. package/src/_stories/ChartEditor.Editor.stories.tsx +3 -5
  25. package/src/_stories/TechAdoptionWithLinks.stories.tsx +27 -0
  26. package/src/_stories/_mock/brush_enabled.json +326 -0
  27. package/src/_stories/_mock/brush_mock.json +2 -69
  28. package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
  29. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +1 -2
  30. package/src/components/Axis/Categorical.Axis.tsx +6 -7
  31. package/src/components/BarChart/components/BarChart.Horizontal.tsx +178 -24
  32. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
  33. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
  34. package/src/components/BarChart/components/BarChart.Vertical.tsx +6 -8
  35. package/src/components/BarChart/components/context.tsx +1 -0
  36. package/src/components/BarChart/helpers/useBarChart.ts +14 -2
  37. package/src/components/Brush/BrushSelector.tsx +1258 -0
  38. package/src/components/Brush/MiniChartPreview.tsx +283 -0
  39. package/src/components/DeviationBar.jsx +9 -7
  40. package/src/components/EditorPanel/EditorPanel.tsx +2711 -2586
  41. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
  42. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +57 -30
  43. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +2 -0
  44. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +30 -25
  45. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +21 -27
  46. package/src/components/EditorPanel/useEditorPermissions.ts +31 -18
  47. package/src/components/Legend/Legend.tsx +3 -2
  48. package/src/components/Legend/helpers/createFormatLabels.tsx +151 -2
  49. package/src/components/Legend/helpers/index.ts +10 -6
  50. package/src/components/LinearChart.tsx +495 -430
  51. package/src/components/PairedBarChart.jsx +20 -3
  52. package/src/components/Regions/components/Regions.tsx +365 -122
  53. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
  54. package/src/components/SmallMultiples/SmallMultipleTile.tsx +5 -1
  55. package/src/components/WarmingStripes/WarmingStripes.tsx +160 -0
  56. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
  57. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
  58. package/src/components/WarmingStripes/index.tsx +3 -0
  59. package/src/data/initial-state.js +3 -1
  60. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
  61. package/src/helpers/getMinMax.ts +12 -7
  62. package/src/helpers/sizeHelpers.ts +0 -20
  63. package/src/helpers/smallMultiplesHelpers.ts +1 -1
  64. package/src/hooks/useChartHoverAnalytics.tsx +10 -9
  65. package/src/hooks/useScales.ts +11 -1
  66. package/src/hooks/useTooltip.tsx +31 -10
  67. package/src/scss/DataTable.scss +0 -4
  68. package/src/scss/main.scss +17 -3
  69. package/src/test/CdcChart.test.jsx +1 -1
  70. package/src/types/ChartConfig.ts +3 -0
  71. package/src/types/Label.ts +1 -0
  72. package/src/utils/analyticsTracking.ts +19 -0
  73. package/LICENSE +0 -201
  74. package/src/components/Brush/BrushChart.tsx +0 -128
  75. package/src/components/Brush/BrushController.tsx +0 -71
  76. package/src/components/Brush/types.tsx +0 -8
  77. package/src/components/BrushChart.tsx +0 -223
@@ -0,0 +1,160 @@
1
+ import { useContext, useState } from 'react'
2
+ import ConfigContext from '../../ConfigContext'
3
+ import { Group } from '@visx/group'
4
+ import { scaleSequential } from 'd3-scale'
5
+ import { interpolateRgbBasis } from 'd3-interpolate'
6
+ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
7
+ import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
8
+ import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
9
+ import { getColorPaletteVersion } from '@cdc/core/helpers/getColorPaletteVersion'
10
+ import { getFallbackColorPalette, migratePaletteWithMap } from '@cdc/core/helpers/palettes/utils'
11
+ import { paletteMigrationMap } from '@cdc/core/helpers/palettes/migratePaletteName'
12
+ import { hasTrackedHover, markHoverTracked } from '../../utils/analyticsTracking'
13
+
14
+ type WarmingStripesProps = {
15
+ xScale: any
16
+ yScale: any
17
+ xMax: number
18
+ yMax: number
19
+ }
20
+
21
+ const WarmingStripes = ({ xMax, yMax }: WarmingStripesProps) => {
22
+ const { transformedData: data, config, formatNumber, interactionLabel, currentViewport } = useContext(ConfigContext)
23
+
24
+ const [currentHover, setCurrentHover] = useState<number | null>(null)
25
+
26
+ // Get the data key for the temperature/anomaly values
27
+ // Use the first series key as the value column
28
+ const valueKey = config.runtime.seriesKeys?.[0]
29
+ const xAxisDataKey = config.runtime.originalXAxis?.dataKey || config.xAxis?.dataKey
30
+
31
+ if (!valueKey || !xAxisDataKey || !data || data.length === 0) {
32
+ return null
33
+ }
34
+
35
+ // Determine max stripes based on viewport
36
+ const isMobile = ['xxs', 'xs', 'sm', 'md'].includes(currentViewport)
37
+ const maxStripes = isMobile ? 60 : 200
38
+
39
+ // Sample data if we have more than the max allowed stripes
40
+ let displayData = data
41
+ if (data.length > maxStripes) {
42
+ const step = data.length / maxStripes
43
+ displayData = []
44
+ for (let i = 0; i < maxStripes; i++) {
45
+ const index = Math.floor(i * step)
46
+ displayData.push(data[index])
47
+ }
48
+ }
49
+
50
+ // Calculate the min and max values for the color scale
51
+ const values = data.map(d => Number(d[valueKey])).filter(v => !isNaN(v))
52
+ const minValue = Math.min(...values)
53
+ const maxValue = Math.max(...values)
54
+
55
+ // Get the color palette from config
56
+ const colorPalettes = filterChartColorPalettes(config)
57
+ const configPalette = config.general?.palette?.name
58
+ const migratedPaletteName = configPalette ? configPalette : getFallbackColorPalette(config)
59
+
60
+ // Check if the palette name ends with 'reverse' and get the base palette
61
+ const isReversedPalette = migratedPaletteName?.endsWith('reverse')
62
+ const basePaletteName = isReversedPalette ? migratedPaletteName.slice(0, -7) : migratedPaletteName
63
+
64
+ let palette =
65
+ colorPalettes[migratePaletteWithMap(basePaletteName, paletteMigrationMap, false)] ||
66
+ colorPalettes[basePaletteName] ||
67
+ colorPalettes[configPalette]
68
+
69
+ // Fallback to a default diverging palette if none found
70
+ if (!palette || palette.length < 2) {
71
+ console.warn(`Palette "${configPalette}" not found or invalid, falling back to default`)
72
+ // Use a default blue to red palette
73
+ palette = [
74
+ '#053061',
75
+ '#2166ac',
76
+ '#4393c3',
77
+ '#92c5de',
78
+ '#d1e5f0',
79
+ '#f7f7f7',
80
+ '#fddbc7',
81
+ '#f4a582',
82
+ '#d6604d',
83
+ '#b2182b',
84
+ '#67001f'
85
+ ]
86
+ }
87
+
88
+ // Create a sequential color scale using the palette colors
89
+ // Apply reverse if configured (either via isReversed flag or 'reverse' suffix in name)
90
+ const shouldReverse = config.general?.palette?.isReversed || isReversedPalette
91
+ const finalPalette = shouldReverse ? [...palette].reverse() : palette
92
+ const colorScale = scaleSequential(interpolateRgbBasis(finalPalette)).domain([minValue, maxValue])
93
+
94
+ // Calculate stripe width based on available space and display data
95
+ const stripeWidth = xMax / displayData.length
96
+
97
+ const handleTooltip = (item: any) => {
98
+ const xValue = item[xAxisDataKey]
99
+ const yValue = item[valueKey]
100
+ const formattedValue = formatNumber(yValue, 'left')
101
+
102
+ return `<div>
103
+ <strong>${config.xAxis.label || xAxisDataKey}:</strong> ${xValue}<br/>
104
+ <strong>${config.runtime.seriesLabels?.[valueKey] || valueKey}:</strong> ${formattedValue}
105
+ </div>`
106
+ }
107
+
108
+ return (
109
+ <Group className='warming-stripes' left={config.yAxis.size}>
110
+ {displayData.map((item, index) => {
111
+ const value = Number(item[valueKey])
112
+ if (isNaN(value)) return null
113
+
114
+ const xPosition = index * stripeWidth
115
+ const fillColor = colorScale(value) as unknown as string
116
+ const isHovered = currentHover === index
117
+ const isMuted = currentHover !== null && !isHovered
118
+
119
+ return (
120
+ <rect
121
+ key={`stripe-${index}`}
122
+ x={xPosition}
123
+ y={0}
124
+ width={stripeWidth}
125
+ height={yMax}
126
+ fill={fillColor}
127
+ fillOpacity={isMuted ? 0.5 : 1}
128
+ stroke='none'
129
+ data-tooltip-html={handleTooltip(item)}
130
+ data-tooltip-id={`cdc-open-viz-tooltip-${config.runtime.uniqueId}`}
131
+ tabIndex={-1}
132
+ style={{ cursor: 'pointer', transition: 'fill-opacity 0.2s ease' }}
133
+ onMouseEnter={() => {
134
+ if (currentHover !== index) {
135
+ // Only publish analytics event once per visualization (shared tracking)
136
+ const vizId = String(config.runtime.uniqueId)
137
+ if (!hasTrackedHover(vizId)) {
138
+ publishAnalyticsEvent({
139
+ vizType: config?.type,
140
+ vizSubType: getVizSubType(config),
141
+ eventType: 'chart_hover',
142
+ eventAction: 'hover',
143
+ eventLabel: interactionLabel || 'unknown',
144
+ vizTitle: getVizTitle(config),
145
+ series: valueKey
146
+ })
147
+ markHoverTracked(vizId)
148
+ }
149
+ setCurrentHover(index)
150
+ }
151
+ }}
152
+ onMouseLeave={() => setCurrentHover(null)}
153
+ />
154
+ )
155
+ })}
156
+ </Group>
157
+ )
158
+ }
159
+
160
+ export default WarmingStripes
@@ -0,0 +1,35 @@
1
+ .warming-stripes-gradient-legend {
2
+ margin: 1rem 0;
3
+ width: 100%;
4
+ }
5
+
6
+ .warming-stripes-gradient-legend__title {
7
+ font-weight: 600;
8
+ margin-bottom: 0.5rem;
9
+ font-size: 16px;
10
+ }
11
+
12
+ .warming-stripes-gradient-legend__description {
13
+ margin-top: 0.5rem;
14
+ margin-bottom: 1rem;
15
+ font-size: 14px;
16
+ color: #555;
17
+ }
18
+
19
+ .warming-stripes-gradient-legend__container {
20
+ width: 100%;
21
+ }
22
+
23
+ .warming-stripes-gradient-legend__svg {
24
+ display: block;
25
+ width: 100%;
26
+ overflow: visible;
27
+ }
28
+
29
+ .warming-stripes-gradient-legend__series-label {
30
+ text-align: center;
31
+ margin-top: 0.5rem;
32
+ font-size: 14px;
33
+ font-weight: 500;
34
+ color: #333;
35
+ }
@@ -0,0 +1,104 @@
1
+ import { useContext } from 'react'
2
+ import ConfigContext from '../../ConfigContext'
3
+ import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
4
+ import { getFallbackColorPalette, migratePaletteWithMap } from '@cdc/core/helpers/palettes/utils'
5
+ import { paletteMigrationMap } from '@cdc/core/helpers/palettes/migratePaletteName'
6
+ import './WarmingStripesGradientLegend.css'
7
+
8
+ const WarmingStripesGradientLegend = () => {
9
+ const { transformedData: data, config, formatNumber } = useContext(ConfigContext)
10
+
11
+ const valueKey = config.runtime.seriesKeys?.[0]
12
+
13
+ if (!valueKey || !data || data.length === 0 || config.legend?.hide) {
14
+ return null
15
+ }
16
+
17
+ // Calculate min and max values
18
+ const values = data.map(d => Number(d[valueKey])).filter(v => !isNaN(v))
19
+ const minValue = Math.min(...values)
20
+ const maxValue = Math.max(...values)
21
+
22
+ // Get the color palette from config (same logic as WarmingStripes component)
23
+ const colorPalettes = filterChartColorPalettes(config)
24
+ const configPalette = config.general?.palette?.name
25
+ const migratedPaletteName = configPalette ? configPalette : getFallbackColorPalette(config)
26
+
27
+ const isReversedPalette = migratedPaletteName?.endsWith('reverse')
28
+ const basePaletteName = isReversedPalette ? migratedPaletteName.slice(0, -7) : migratedPaletteName
29
+
30
+ let palette =
31
+ colorPalettes[migratePaletteWithMap(basePaletteName, paletteMigrationMap, false)] ||
32
+ colorPalettes[basePaletteName] ||
33
+ colorPalettes[configPalette]
34
+
35
+ if (!palette || palette.length < 2) {
36
+ palette = [
37
+ '#053061',
38
+ '#2166ac',
39
+ '#4393c3',
40
+ '#92c5de',
41
+ '#d1e5f0',
42
+ '#f7f7f7',
43
+ '#fddbc7',
44
+ '#f4a582',
45
+ '#d6604d',
46
+ '#b2182b',
47
+ '#67001f'
48
+ ]
49
+ }
50
+
51
+ const shouldReverse = config.general?.palette?.isReversed || isReversedPalette
52
+ const finalPalette = shouldReverse ? [...palette].reverse() : palette
53
+
54
+ // Create gradient stops for SVG
55
+ const gradientStops = finalPalette.map((color, index) => {
56
+ const offset = (index / (finalPalette.length - 1)) * 100
57
+ return { offset: `${offset}%`, color }
58
+ })
59
+
60
+ const seriesLabel = config.runtime.seriesLabels?.[valueKey] || valueKey
61
+ const uniqueId = `warming-stripes-gradient-${config.runtime.uniqueId}`
62
+
63
+ return (
64
+ <div className='warming-stripes-gradient-legend'>
65
+ {config.legend?.label && <h3 className='warming-stripes-gradient-legend__title'>{config.legend.label}</h3>}
66
+ {config.legend?.description && (
67
+ <p className='warming-stripes-gradient-legend__description'>{config.legend.description}</p>
68
+ )}
69
+
70
+ <div className='warming-stripes-gradient-legend__container'>
71
+ <svg className='warming-stripes-gradient-legend__svg' height='50' width='100%'>
72
+ <defs>
73
+ <linearGradient id={uniqueId} x1='0%' y1='0%' x2='100%' y2='0%'>
74
+ {gradientStops.map((stop, index) => (
75
+ <stop key={index} offset={stop.offset} stopColor={stop.color} />
76
+ ))}
77
+ </linearGradient>
78
+ </defs>
79
+
80
+ {/* Border */}
81
+ <rect x='0' y='0' width='100%' height='25' fill='#d3d3d3' />
82
+
83
+ {/* Gradient bar */}
84
+ <rect x='1' y='1' width='calc(100% - 2px)' height='23' fill={`url(#${uniqueId})`} />
85
+
86
+ {/* Min label */}
87
+ <text x='0' y='40' fontSize='14' textAnchor='start' fill='#333'>
88
+ {formatNumber(minValue, 'left')}
89
+ </text>
90
+
91
+ {/* Max label */}
92
+ <text x='100%' y='40' fontSize='14' textAnchor='end' fill='#333'>
93
+ {formatNumber(maxValue, 'left')}
94
+ </text>
95
+ </svg>
96
+
97
+ {/* Series name centered below gradient */}
98
+ <div className='warming-stripes-gradient-legend__series-label'>{seriesLabel}</div>
99
+ </div>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ export default WarmingStripesGradientLegend
@@ -0,0 +1,3 @@
1
+ import WarmingStripes from './WarmingStripes'
2
+
3
+ export default WarmingStripes
@@ -23,6 +23,7 @@ const createInitialState = () => {
23
23
  noData: 'No Data Available'
24
24
  },
25
25
  title: '',
26
+ titleStyle: 'small',
26
27
  showTitle: true,
27
28
  showDownloadMediaButton: false,
28
29
  theme: 'theme-blue',
@@ -139,7 +140,8 @@ const createInitialState = () => {
139
140
  padding: 5,
140
141
  showYearsOnce: false,
141
142
  sortByRecentDate: false,
142
- brushActive: false
143
+ brushActive: false,
144
+ brushDefaultRecentDateCount: undefined
143
145
  },
144
146
  table: {
145
147
  label: 'Data Table',
@@ -0,0 +1,57 @@
1
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
2
+
3
+ interface CalculateHorizontalBarCategoryLabelWidthProps {
4
+ yScale: any
5
+ chartWidth: number
6
+ formatDate: Function
7
+ parseDate: Function
8
+ tickLabelFont: string
9
+ xAxisType?: string
10
+ labelPlacement?: string
11
+ }
12
+
13
+ /**
14
+ * Helper function to calculate category label space for horizontal bar charts
15
+ *
16
+ * @param props Configuration object with chart properties
17
+ * @returns Calculated category label space, capped at 30% of parent width
18
+ */
19
+ export const calculateHorizontalBarCategoryLabelWidth = ({
20
+ yScale,
21
+ chartWidth,
22
+ formatDate,
23
+ parseDate,
24
+ tickLabelFont,
25
+ xAxisType,
26
+ labelPlacement
27
+ }: CalculateHorizontalBarCategoryLabelWidthProps): number => {
28
+ if (labelPlacement !== 'On Date/Category Axis') return 0
29
+
30
+ const categoryValues = yScale.domain()
31
+
32
+ if (!categoryValues || categoryValues.length === 0) {
33
+ return chartWidth * 0.3
34
+ }
35
+
36
+ const formattedLabels = categoryValues.map(value => {
37
+ if (xAxisType === 'date') {
38
+ try {
39
+ return formatDate(parseDate(value))
40
+ } catch (e) {
41
+ return String(value)
42
+ }
43
+ }
44
+ return String(value)
45
+ })
46
+
47
+ const labelWidths = formattedLabels.map(label => getTextWidth(label, tickLabelFont))
48
+ const maxLabelWidth = Math.max(...labelWidths)
49
+
50
+ // We need some extra padding or visx will wrap labels too early
51
+ const paddedWidth = maxLabelWidth + Math.ceil(maxLabelWidth * 0.15)
52
+
53
+ // Allocate at most 30% of chart width to category labels
54
+ const maxAllowedWidth = chartWidth * 0.3
55
+
56
+ return Math.min(paddedWidth, maxAllowedWidth)
57
+ }
@@ -55,10 +55,15 @@ const getMinMax = ({
55
55
  max = enteredMaxValue && isMaxValid ? Number(enteredMaxValue) : Number.MIN_VALUE
56
56
  const { lower, upper } = config?.confidenceKeys || {}
57
57
 
58
+ // When brush is active, use tableData (full dataset) for min/max calculations
59
+ // so the y-axis shows the full range, but still use filtered data for rendering
60
+ const dataForMinMax = config.xAxis.brushActive && tableData && tableData.length > 0 ? tableData : data
61
+
58
62
  if (lower && upper && config.visualizationType === 'Bar') {
59
63
  const buffer = min < 0 ? 1.1 : 0
60
- const maxValueWithCI = Math.max(...data.flatMap(d => [d[upper], d[lower]])) * paddingAddedToAxis
61
- const minValueWithCIPlusBuffer = Math.min(...data.flatMap(d => [d[upper], d[lower]])) * paddingAddedToAxis * buffer
64
+ const maxValueWithCI = Math.max(...dataForMinMax.flatMap(d => [d[upper], d[lower]])) * paddingAddedToAxis
65
+ const minValueWithCIPlusBuffer =
66
+ Math.min(...dataForMinMax.flatMap(d => [d[upper], d[lower]])) * paddingAddedToAxis * buffer
62
67
  max = max > maxValueWithCI ? max : maxValueWithCI
63
68
  min = min < minValueWithCIPlusBuffer ? min : minValueWithCIPlusBuffer
64
69
  }
@@ -79,7 +84,7 @@ const getMinMax = ({
79
84
  })
80
85
 
81
86
  // Using the columnNames or "keys" get the returned result
82
- const result = data.map(obj => columnNames.map(key => obj[key]))
87
+ const result = dataForMinMax.map(obj => columnNames.map(key => obj[key]))
83
88
 
84
89
  const highCIGroup = Math.max.apply(
85
90
  null,
@@ -102,7 +107,7 @@ const getMinMax = ({
102
107
 
103
108
  if (visualizationType === 'Combo') {
104
109
  try {
105
- if (!data) throw new Error('COVE: missing data while getting min/max for combo chart.')
110
+ if (!dataForMinMax) throw new Error('COVE: missing data while getting min/max for combo chart.')
106
111
  // seperate the left and right axis items & get each sides series keys
107
112
  let leftAxisSeriesItems = series.filter(s => s.axis === 'Left')
108
113
  let rightAxisSeriesItems = series.filter(s => s.axis === 'Right')
@@ -128,8 +133,8 @@ const getMinMax = ({
128
133
  })
129
134
  return max
130
135
  }
131
- leftMax = findMaxFromSeriesKeys(data, leftAxisSeriesItems, leftMax, 'left')
132
- rightMax = findMaxFromSeriesKeys(data, rightAxisSeriesItems, rightMax, 'right')
136
+ leftMax = findMaxFromSeriesKeys(dataForMinMax, leftAxisSeriesItems, leftMax, 'left')
137
+ rightMax = findMaxFromSeriesKeys(dataForMinMax, rightAxisSeriesItems, rightMax, 'right')
133
138
 
134
139
  if (leftMax < Number(enteredMaxValue)) {
135
140
  leftMax = Number(enteredMaxValue)
@@ -209,7 +214,7 @@ const getMinMax = ({
209
214
  }
210
215
 
211
216
  if (config.isLollipopChart && config.yAxis.displayNumbersOnBar) {
212
- const dataKey = data.map(item => item[config.series[0].dataKey])
217
+ const dataKey = dataForMinMax.map(item => item[config.series[0].dataKey])
213
218
  const maxDataVal = Math.max(...dataKey).toString().length
214
219
 
215
220
  switch (true) {
@@ -26,23 +26,3 @@ export function calcInitialHeight(
26
26
  const height = Number(heights?.[renderedOrientation])
27
27
  return isNaN(height) ? 0 : height
28
28
  }
29
-
30
- export function handleAutoPaddingRight(parentRef, xAxisLabelRefs, parentWidth): [boolean, number] {
31
- const parentX = parentRef.current.getBoundingClientRect().x
32
- const editorIsOpen = !!document.querySelector('.editor-panel:not(.hidden)')
33
- const lastTickRect = xAxisLabelRefs.current?.[xAxisLabelRefs.current.length - 1]?.getBoundingClientRect()
34
- const lastBottomTickEnd = lastTickRect ? lastTickRect.x + lastTickRect.width : 0
35
- const editorWidth = editorIsOpen ? EDITOR_WIDTH : 0
36
- const calculatedOverhang = lastBottomTickEnd - parentX - editorWidth - parentWidth
37
-
38
- const paddingToAdd = clamp(calculatedOverhang, 0, 20)
39
- const currentPadding = Number(parentRef.current.style.paddingRight.replace('px', ''))
40
- const paddingDiff = Math.abs(currentPadding - paddingToAdd)
41
- const DIFF_THRESHOLD = 5
42
-
43
- const noChange = currentPadding === calculatedOverhang
44
- const insufficientDiff = (paddingDiff < DIFF_THRESHOLD && calculatedOverhang > 0) || Math.abs(calculatedOverhang) < 1
45
- const updatePadding = !noChange && !insufficientDiff
46
-
47
- return [updatePadding, paddingToAdd]
48
- }
@@ -181,7 +181,7 @@ export const getTileDisplayTitle = (mode, seriesKey, tileValue, tileKey, config)
181
181
  * Get the full color palette from config with exactly the number of colors needed
182
182
  * This creates a temporary colorScale with the right number of series to get the needed colors
183
183
  */
184
- const getFullColorPalette = (config, numberOfTiles) => {
184
+ export const getFullColorPalette = (config, numberOfTiles) => {
185
185
  // Create fake series keys for exactly the number of tiles needed
186
186
  const tempSeriesKeys = Array(numberOfTiles)
187
187
  .fill(null)
@@ -1,6 +1,6 @@
1
- import { useRef } from 'react'
2
1
  import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
3
2
  import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
3
+ import { hasTrackedHover, markHoverTracked } from '../utils/analyticsTracking'
4
4
 
5
5
  type UseChartHoverAnalyticsParams = {
6
6
  config: any
@@ -9,14 +9,16 @@ type UseChartHoverAnalyticsParams = {
9
9
 
10
10
  /**
11
11
  * Hook to track analytics when user enters the chart area
12
- * Fires once per chart entry, not on every hover interaction
12
+ * Fires once per visualization, persists across component remounts
13
13
  */
14
14
  export const useChartHoverAnalytics = ({ config, interactionLabel = '' }: UseChartHoverAnalyticsParams) => {
15
- const hasTrackedRef = useRef(false)
16
-
17
15
  const handleChartMouseEnter = () => {
18
- // Only track if we have an interaction label and haven't tracked yet
19
- if (!interactionLabel || hasTrackedRef.current) return
16
+ // Only track if we have an interaction label
17
+ if (!interactionLabel) return
18
+
19
+ // Use unique ID to track per visualization
20
+ const vizId = String(config.runtime.uniqueId)
21
+ if (hasTrackedHover(vizId)) return
20
22
 
21
23
  // Publish the analytics event
22
24
  publishAnalyticsEvent({
@@ -29,12 +31,11 @@ export const useChartHoverAnalytics = ({ config, interactionLabel = '' }: UseCha
29
31
  })
30
32
 
31
33
  // Mark as tracked so we don't fire again
32
- hasTrackedRef.current = true
34
+ markHoverTracked(vizId)
33
35
  }
34
36
 
35
37
  const handleChartMouseLeave = () => {
36
- // Reset tracking when mouse leaves so next entry will track
37
- hasTrackedRef.current = false
38
+ // No-op: We no longer reset tracking on mouse leave
38
39
  }
39
40
 
40
41
  return {
@@ -133,7 +133,17 @@ const useScales = (properties: useScaleProps) => {
133
133
  // handle Vertical bars
134
134
  if (!isHorizontal) {
135
135
  xScale = composeScaleBand(xAxisDataMapped, [0, xMax], paddingRange)
136
- yScale = composeYScale({ min, max, yMax, config, leftMax })
136
+ // For categorical y-axis, use [0, max] domain and [yMax, 0] range to match CategoricalYAxis
137
+ // This ensures line data aligns with categorical bars and bars go to 100% height
138
+ if (config.yAxis.type === 'categorical') {
139
+ yScale = scaleLinear({
140
+ domain: [0, max],
141
+ range: [yMax, 0],
142
+ clamp: true
143
+ })
144
+ } else {
145
+ yScale = composeYScale({ min, max, yMax, config, leftMax })
146
+ }
137
147
  seriesScale = composeScaleBand(seriesDomain, [0, xScale.bandwidth()], 0)
138
148
  }
139
149
 
@@ -105,7 +105,7 @@ export const useTooltip = props => {
105
105
  addColCommas: column.commas
106
106
  }
107
107
 
108
- const pieColumnData = additionalChartData?.arc?.data[column.name]
108
+ const pieColumnData = additionalChartData?.data?.[column.name]
109
109
  const columnData =
110
110
  config.tooltips.singleSeries && visualizationType === 'Line'
111
111
  ? resolvedScaleValues.filter(
@@ -145,7 +145,7 @@ export const useTooltip = props => {
145
145
  tooltipItems.push(
146
146
  [config.xAxis.dataKey, pieData[config.xAxis.dataKey]],
147
147
  [
148
- config.runtime.yAxis.dataKey,
148
+ config.runtime.yAxis.label || config.runtime.yAxis.dataKey,
149
149
  showPiePercent ? pctString(actualPieValue) : formatNumber(pieData[config.runtime.yAxis.dataKey])
150
150
  ],
151
151
  showPiePercent ? [] : ['Percent', pctString(pctOf360)]
@@ -260,11 +260,30 @@ export const useTooltip = props => {
260
260
  const dataXPosition = eventSvgCoords.x + 10
261
261
  const dataYPosition = eventSvgCoords.y
262
262
 
263
+ // Helper to strip <a> tags and only show link text
264
+ function stripLinkTags(str) {
265
+ if (typeof str !== 'string') return str
266
+ // Remove HTML <a> tags, keep inner text
267
+ return str.replace(/<a [^>]*>(.*?)<\/a>/gi, '$1')
268
+ }
269
+
270
+ // Strip link tags from all tooltip values
271
+ const cleanTooltipItems = [...tooltipItems, ...additionalTooltipItems].map(item => {
272
+ // item can be [key, value] or [key, value, axisPosition]
273
+ if (Array.isArray(item)) {
274
+ // Only strip from value (item[1])
275
+ const newItem = [...item]
276
+ newItem[1] = stripLinkTags(newItem[1])
277
+ return newItem
278
+ }
279
+ return item
280
+ })
281
+
263
282
  const tooltipInformation = {
264
283
  tooltipLeft: dataXPosition,
265
284
  tooltipTop: dataYPosition,
266
285
  tooltipData: {
267
- data: [...tooltipItems, ...additionalTooltipItems],
286
+ data: cleanTooltipItems,
268
287
  dataXPosition,
269
288
  dataYPosition
270
289
  }
@@ -523,14 +542,16 @@ export const useTooltip = props => {
523
542
  includedSeries.push(...dynamicDataCategories)
524
543
 
525
544
  if (config.visualizationType === 'Forecasting') {
526
- config.runtime.series.map(s => {
527
- s.confidenceIntervals.map(c => {
528
- if (c.showInTooltip) {
529
- includedSeries.push(c.high)
530
- includedSeries.push(c.low)
531
- }
545
+ config.runtime.series
546
+ .filter(s => s.type === 'Forecasting' && s.confidenceIntervals)
547
+ .forEach(s => {
548
+ s.confidenceIntervals.forEach(c => {
549
+ if (c.showInTooltip) {
550
+ includedSeries.push(c.high)
551
+ includedSeries.push(c.low)
552
+ }
553
+ })
532
554
  })
533
- })
534
555
  }
535
556
 
536
557
  const colNames = Object.values(config.columns).map(column => column.name)
@@ -1,8 +1,4 @@
1
1
  .data-table-container {
2
- &.brush-active {
3
- margin: 80px 0 0;
4
- }
5
-
6
2
  width: 100%;
7
3
  }
8
4
 
@@ -50,9 +50,6 @@
50
50
  .subtext,
51
51
  .subtext--responsive-ticks,
52
52
  .section-subtext {
53
- &--brush-active {
54
- margin-top: 3rem !important;
55
- }
56
53
  }
57
54
 
58
55
  .type-pie {
@@ -325,6 +322,23 @@
325
322
  margin-bottom: 2.5em;
326
323
  }
327
324
 
325
+ // Brush touch support
326
+ .brush-overlay {
327
+ touch-action: none;
328
+ -webkit-touch-callout: none;
329
+ -webkit-user-select: none;
330
+ user-select: none;
331
+
332
+ .visx-brush,
333
+ .visx-brush-overlay,
334
+ .visx-brush-selection,
335
+ .visx-brush-handle-left,
336
+ .visx-brush-handle-right {
337
+ touch-action: none;
338
+ cursor: pointer;
339
+ }
340
+ }
341
+
328
342
  svg.dragging-annotation * {
329
343
  user-select: none;
330
344
  }
@@ -7,5 +7,5 @@ describe('Chart', () => {
7
7
  const pkgDir = path.join(__dirname, '..')
8
8
  const result = testStandaloneBuild(pkgDir)
9
9
  expect(result).toBe(true)
10
- })
10
+ }, 300000)
11
11
  })