@cdc/chart 4.26.2 → 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 (70) hide show
  1. package/LICENSE +201 -0
  2. package/dist/cdcchart.js +35674 -32430
  3. package/examples/data/data-with-metadata.json +10 -0
  4. package/examples/feature/pie/planet-pie-example-config.json +2 -1
  5. package/examples/metadata-variables.json +58 -0
  6. package/package.json +3 -3
  7. package/src/CdcChart.tsx +8 -4
  8. package/src/CdcChartComponent.tsx +321 -288
  9. package/src/_stories/Chart.CustomColors.stories.tsx +74 -0
  10. package/src/_stories/Chart.Defaults.stories.tsx +95 -0
  11. package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
  12. package/src/_stories/Chart.stories.tsx +36 -2
  13. package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
  14. package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
  15. package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
  16. package/src/_stories/_mock/paired-bar-abbr.json +421 -0
  17. package/src/_stories/_mock/pie_custom_colors.json +268 -0
  18. package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
  19. package/src/components/Annotations/components/AnnotationDraggable.styles.css +10 -10
  20. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -1
  21. package/src/components/Annotations/components/AnnotationList.styles.css +11 -11
  22. package/src/components/Axis/BottomAxis.tsx +10 -3
  23. package/src/components/Axis/PairedBarAxis.tsx +10 -4
  24. package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
  25. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
  26. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
  27. package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
  28. package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
  29. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
  30. package/src/components/BarChart/helpers/useBarChart.ts +3 -0
  31. package/src/components/Brush/BrushSelector.tsx +2 -1
  32. package/src/components/Brush/MiniChartPreview.tsx +21 -26
  33. package/src/components/EditorPanel/EditorPanel.tsx +56 -43
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +9 -9
  35. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
  36. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +39 -1
  37. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +24 -42
  38. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -2
  39. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +45 -42
  40. package/src/components/EditorPanel/editor-panel.scss +1 -1
  41. package/src/components/ForestPlot/ForestPlot.tsx +26 -22
  42. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
  43. package/src/components/Legend/helpers/createFormatLabels.tsx +3 -2
  44. package/src/components/LinearChart/tests/LinearChart.test.tsx +77 -0
  45. package/src/components/LinearChart/tests/mockConfigContext.ts +2 -0
  46. package/src/components/LinearChart.tsx +26 -6
  47. package/src/components/PieChart/PieChart.tsx +19 -4
  48. package/src/components/RadarChart/RadarChart.tsx +1 -1
  49. package/src/components/Regions/components/Regions.tsx +6 -6
  50. package/src/components/Sankey/components/Sankey.tsx +3 -3
  51. package/src/components/Sankey/sankey.scss +1 -1
  52. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  53. package/src/components/Sparkline/index.scss +4 -2
  54. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
  55. package/src/data/initial-state.js +23 -14
  56. package/src/data/legacy-defaults.ts +18 -0
  57. package/src/helpers/abbreviateNumber.ts +24 -17
  58. package/src/helpers/getChartPatternId.ts +17 -0
  59. package/src/helpers/getMinMax.ts +16 -2
  60. package/src/helpers/seriesColumnSettings.ts +114 -0
  61. package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
  62. package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
  63. package/src/hooks/useRightAxis.ts +14 -0
  64. package/src/hooks/useScales.ts +92 -56
  65. package/src/hooks/useTooltip.tsx +20 -3
  66. package/src/scss/main.scss +152 -79
  67. package/src/test/CdcChart.test.jsx +2 -2
  68. package/src/types/ChartConfig.ts +4 -0
  69. package/tests/fixtures/chart-config-with-metadata.json +29 -0
  70. package/tests/fixtures/data-with-metadata.json +10 -0
@@ -0,0 +1,18 @@
1
+ // Preserves the OLD default values for properties changed in initial-state.js.
2
+ // When the backfill loop fills a missing property, it uses these values instead
3
+ // of the current defaults so that existing configs aren't visually affected.
4
+ //
5
+ // - Changed defaults: record the ORIGINAL value before any changes.
6
+ // - New properties: set to `undefined` so they are not backfilled at all.
7
+ //
8
+ // See backfillDefaults() in @cdc/core for the shared fill logic.
9
+ export const LEGACY_CHART_DEFAULTS: Record<string, Record<string, unknown>> = {
10
+ general: { useIntelligentLineChartLabels: undefined },
11
+ yAxis: { hideAxis: false, hideTicks: false, gridLines: false, numTicks: '' },
12
+ xAxis: { numTicks: '', dateDisplayFormat: undefined, viewportNumTicks: undefined },
13
+ table: { expanded: true, dateDisplayFormat: '' },
14
+ legend: { position: 'right' },
15
+ dataFormat: { commas: false },
16
+ tooltips: { dateDisplayFormat: '' },
17
+ visual: { border: false, accent: false, background: false }
18
+ }
@@ -1,17 +1,24 @@
1
- export const abbreviateNumber = num => {
2
- let unit = ''
3
- let absNum = Math.abs(num)
4
-
5
- if (absNum >= 1e9) {
6
- unit = 'B'
7
- num = num / 1e9
8
- } else if (absNum >= 1e6) {
9
- unit = 'M'
10
- num = num / 1e6
11
- } else if (absNum >= 1e3) {
12
- unit = 'K'
13
- num = num / 1e3
14
- }
15
-
16
- return num + unit
17
- }
1
+ const abbreviationUnits: Record<string, { K: string; M: string; B: string }> = {
2
+ 'es-MX': { K: ' mil', M: ' M', B: ' mil M' }
3
+ }
4
+
5
+ const defaultUnits = { K: 'K', M: 'M', B: 'B' }
6
+
7
+ export const abbreviateNumber = (num, locale?: string) => {
8
+ const units = (locale && abbreviationUnits[locale]) || defaultUnits
9
+ let unit = ''
10
+ let absNum = Math.abs(num)
11
+
12
+ if (absNum >= 1e9) {
13
+ unit = units.B
14
+ num = num / 1e9
15
+ } else if (absNum >= 1e6) {
16
+ unit = units.M
17
+ num = num / 1e6
18
+ } else if (absNum >= 1e3) {
19
+ unit = units.K
20
+ num = num / 1e3
21
+ }
22
+
23
+ return num + unit
24
+ }
@@ -0,0 +1,17 @@
1
+ import { sanitizeToSvgId } from '@cdc/core/helpers/cove/string'
2
+
3
+ const getStableKeyHash = (input: string): string => {
4
+ // djb2 variant, deterministic and cheap for short editor keys
5
+ let hash = 5381
6
+ for (let i = 0; i < input.length; i++) {
7
+ hash = (hash * 33) ^ input.charCodeAt(i)
8
+ }
9
+ return (hash >>> 0).toString(36)
10
+ }
11
+
12
+ export const getChartPatternId = (patternKey: string): string => {
13
+ const rawKey = String(patternKey)
14
+ const sanitizedKey = sanitizeToSvgId(rawKey)
15
+ const hashSuffix = getStableKeyHash(rawKey)
16
+ return `chart-pattern-${sanitizedKey}-${hashSuffix}`
17
+ }
@@ -37,8 +37,8 @@ const getMinMax = ({
37
37
  let leftMax = 0
38
38
  let rightMax = 0
39
39
 
40
- if (!data) {
41
- return { min, max }
40
+ if (!data || !config.runtime) {
41
+ return { min, max, leftMax, rightMax }
42
42
  }
43
43
 
44
44
  const { visualizationType, series } = config
@@ -248,6 +248,20 @@ const getMinMax = ({
248
248
  min = min / 1.1
249
249
  }
250
250
 
251
+ // Enforce smallest left axis max so small-data charts don't show misleading decimal ticks
252
+ const smallestLeftAxisMaxRaw = config.yAxis.smallestLeftAxisMax
253
+ if (smallestLeftAxisMaxRaw !== null && smallestLeftAxisMaxRaw !== '') {
254
+ const smallestLeftAxisMax = Number(smallestLeftAxisMaxRaw)
255
+ if (!Number.isNaN(smallestLeftAxisMax)) {
256
+ if (max < smallestLeftAxisMax) {
257
+ max = smallestLeftAxisMax
258
+ }
259
+ if (leftMax < smallestLeftAxisMax) {
260
+ leftMax = smallestLeftAxisMax
261
+ }
262
+ }
263
+ }
264
+
251
265
  return { min, max, leftMax, rightMax }
252
266
  }
253
267
  export default getMinMax
@@ -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
+ })
@@ -27,6 +27,20 @@ export default function useRightAxis({ config, yMax = 0, data = [] }) {
27
27
  minValue = config.yAxis.rightMin
28
28
  }
29
29
 
30
+ // Enforce smallest right axis max so small-data charts don't show misleading decimal ticks
31
+ const smallestRightAxisMaxRaw = config.yAxis.smallestRightAxisMax
32
+ let smallestRightAxisMax: number | null = null
33
+
34
+ if (smallestRightAxisMaxRaw !== null && smallestRightAxisMaxRaw !== undefined && smallestRightAxisMaxRaw !== '') {
35
+ const coercedSmallestRightAxisMax = Number(smallestRightAxisMaxRaw)
36
+ if (!Number.isNaN(coercedSmallestRightAxisMax)) {
37
+ smallestRightAxisMax = coercedSmallestRightAxisMax
38
+ }
39
+ }
40
+
41
+ if (smallestRightAxisMax !== null && max < smallestRightAxisMax) {
42
+ max = smallestRightAxisMax
43
+ }
30
44
  // if there is a bar series & the right axis doesn't include a negative number, default to zero
31
45
  const hasBarSeries = config.runtime?.barSeriesKeys?.length > 0
32
46
  const hasLineSeries = config.runtime?.lineSeriesKeys?.length > 0
@@ -9,6 +9,7 @@ import {
9
9
  getTicks
10
10
  } from '@visx/scale'
11
11
  import { useContext } from 'react'
12
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
12
13
  import ConfigContext from '../ConfigContext'
13
14
  import { ChartConfig } from '../types/ChartConfig'
14
15
  import { ChartContext } from '../types/ChartContext'
@@ -59,9 +60,8 @@ const useScales = (properties: useScaleProps) => {
59
60
  } = properties
60
61
 
61
62
  const context = useContext<ChartContext>(ConfigContext)
62
- const { rawData, dimensions, convertLineToBarGraph = false } = context
63
+ const { convertLineToBarGraph = false } = context
63
64
 
64
- const [screenWidth] = dimensions
65
65
  const isHorizontal = config.orientation === 'horizontal'
66
66
  const { visualizationType, xAxis, forestPlot, runtime } = config
67
67
  const isForestPlot = visualizationType === 'Forest Plot'
@@ -308,65 +308,38 @@ const useScales = (properties: useScaleProps) => {
308
308
  }
309
309
 
310
310
  yScale = scaleLinear({
311
- domain: [0, rawData.length],
311
+ domain: [0, data.length],
312
312
  range: resolvedYRange()
313
313
  })
314
314
 
315
315
  const xAxisPadding = 5
316
+ const [plotStart, plotEnd] = getForestPlotRange(config, data as Record<string, any>[], xMax)
317
+
318
+ if (forestPlot.type === 'Linear') {
319
+ xScale = scaleLinear<LinearScaleConfig>({
320
+ domain: [
321
+ Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
322
+ Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
323
+ ],
324
+ range: [plotStart, plotEnd],
325
+ type: scaleTypes.LINEAR
326
+ })
327
+ xScale.type = scaleTypes.LINEAR
328
+ }
316
329
 
317
- const leftWidthOffset = (Number(forestPlot.leftWidthOffset) / 100) * xMax
318
- const rightWidthOffset = (Number(forestPlot.rightWidthOffset) / 100) * xMax
319
-
320
- const rightWidthOffsetMobile = (Number(forestPlot.rightWidthOffsetMobile) / 100) * xMax
321
- const leftWidthOffsetMobile = (Number(forestPlot.leftWidthOffsetMobile) / 100) * xMax
322
-
323
- if (screenWidth > 480) {
324
- if (forestPlot.type === 'Linear') {
325
- xScale = scaleLinear({
326
- domain: [
327
- Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
328
- Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
329
- ],
330
- range: [leftWidthOffset, Number(screenWidth) - rightWidthOffset]
331
- })
332
- xScale.type = scaleTypes.LINEAR
333
- }
334
- if (forestPlot.type === 'Logarithmic') {
335
- let max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
336
- let fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
337
-
338
- xScale = scaleLog<LogScaleConfig>({
339
- domain: [fp_min, max],
340
- range: [leftWidthOffset, xMax - rightWidthOffset],
341
- nice: true
342
- })
343
- xScale.type = scaleTypes.LOG
344
- }
345
- } else {
346
- if (forestPlot.type === 'Linear') {
347
- xScale = scaleLinear<LinearScaleConfig>({
348
- domain: [
349
- Math.min(...data.map(d => parseFloat(d[forestPlot.lower]))) - xAxisPadding,
350
- Math.max(...data.map(d => parseFloat(d[forestPlot.upper]))) + xAxisPadding
351
- ],
352
- range: [leftWidthOffsetMobile, xMax - rightWidthOffsetMobile],
353
- type: scaleTypes.LINEAR
354
- })
355
- }
356
-
357
- if (forestPlot.type === 'Logarithmic') {
358
- let max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
359
- let fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
360
-
361
- xScale = scaleLog<LogScaleConfig>({
362
- domain: [fp_min, max],
363
- range: [leftWidthOffset, xMax - rightWidthOffset],
364
- nice: true,
365
- base: max > 1 ? 10 : 2,
366
- round: false,
367
- type: scaleTypes.LOG
368
- })
369
- }
330
+ if (forestPlot.type === 'Logarithmic') {
331
+ const max = Math.max(...data.map(d => parseFloat(d[forestPlot.upper])))
332
+ const fp_min = Math.min(...data.map(d => parseFloat(d[forestPlot.lower])))
333
+
334
+ xScale = scaleLog<LogScaleConfig>({
335
+ domain: [fp_min, max],
336
+ range: [plotStart, plotEnd],
337
+ nice: true,
338
+ base: max > 1 ? 10 : 2,
339
+ round: false,
340
+ type: scaleTypes.LOG
341
+ })
342
+ xScale.type = scaleTypes.LOG
370
343
  }
371
344
  }
372
345
  return {
@@ -525,3 +498,66 @@ const sortXAxisData = (xAxisData, sortByRecentDate) => {
525
498
  return xAxisData.sort((a, b) => Number(a) - Number(b))
526
499
  }
527
500
  }
501
+
502
+ const FOREST_PLOT_FONT = 'normal 12px Nunito, sans-serif'
503
+ const FOREST_PLOT_GAP = 24
504
+ const FOREST_PLOT_MIN_WIDTH = 120
505
+ const FOREST_PLOT_MAX_LEFT_RATIO = 0.45
506
+ const FOREST_PLOT_MAX_RIGHT_RATIO = 0.35
507
+
508
+ const getForestPlotRange = (config: ChartConfig, data: Record<string, any>[], xMax: number): [number, number] => {
509
+ if (!xMax) return [0, 0]
510
+
511
+ const leftReserve = getForestPlotLeftReserve(config, data, xMax)
512
+ const rightReserve = getForestPlotRightReserve(config, data, xMax)
513
+ const availableReserve = Math.max(xMax - FOREST_PLOT_MIN_WIDTH, 0)
514
+ const totalReserve = leftReserve + rightReserve
515
+
516
+ if (totalReserve <= availableReserve) {
517
+ return [leftReserve, xMax - rightReserve]
518
+ }
519
+
520
+ if (!availableReserve || !totalReserve) {
521
+ return [0, xMax]
522
+ }
523
+
524
+ const reserveScale = availableReserve / totalReserve
525
+ return [leftReserve * reserveScale, xMax - rightReserve * reserveScale]
526
+ }
527
+
528
+ const getForestPlotLeftReserve = (config: ChartConfig, data: Record<string, any>[], xMax: number) => {
529
+ const { forestPlot, xAxis } = config
530
+ const columns = Object.values(config.columns || {}) as Record<string, any>[]
531
+ const studyTextWidth = forestPlot.hideDateCategoryCol
532
+ ? 0
533
+ : getForestPlotTextWidth([xAxis.dataKey, ...data.map(row => row?.[xAxis.dataKey])])
534
+
535
+ const leftColumnExtent = columns
536
+ .filter(column => column?.forestPlot && !column?.forestPlotAlignRight)
537
+ .reduce((maxExtent, column) => {
538
+ const columnStart = Number(column.forestPlotStartingPoint ?? column.startingPoint ?? 0)
539
+ const columnWidth = getForestPlotTextWidth([column.label, ...data.map(row => row?.[column.name])])
540
+ return Math.max(maxExtent, columnStart + columnWidth)
541
+ }, 0)
542
+
543
+ const reserve = Math.max(studyTextWidth, leftColumnExtent)
544
+ return reserve ? Math.min(reserve + FOREST_PLOT_GAP, xMax * FOREST_PLOT_MAX_LEFT_RATIO) : 0
545
+ }
546
+
547
+ const getForestPlotRightReserve = (config: ChartConfig, data: Record<string, any>[], xMax: number) => {
548
+ const columns = Object.values(config.columns || {}) as Record<string, any>[]
549
+ const rightColumnWidth = columns
550
+ .filter(column => column?.forestPlot && column?.forestPlotAlignRight)
551
+ .reduce((maxWidth, column) => {
552
+ const columnWidth = getForestPlotTextWidth([column.label, ...data.map(row => row?.[column.name])])
553
+ return Math.max(maxWidth, columnWidth)
554
+ }, 0)
555
+
556
+ return rightColumnWidth ? Math.min(rightColumnWidth + FOREST_PLOT_GAP, xMax * FOREST_PLOT_MAX_RIGHT_RATIO) : 0
557
+ }
558
+
559
+ const getForestPlotTextWidth = (values: unknown[]) =>
560
+ values.reduce((maxWidth, value) => {
561
+ const text = value === null || value === undefined ? '' : String(value)
562
+ return Math.max(maxWidth, getTextWidth(text, FOREST_PLOT_FONT) || 0)
563
+ }, 0)
@@ -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,
@@ -633,7 +648,9 @@ export const useTooltip = props => {
633
648
  */
634
649
  const getSeriesNameFromLabel = originalColumnName => {
635
650
  let series = config.runtime.series.filter(s => s.dataKey === originalColumnName)
636
- 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
637
654
  return originalColumnName
638
655
  }
639
656