@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.
- package/LICENSE +201 -0
- package/dist/cdcchart.js +35674 -32430
- package/examples/data/data-with-metadata.json +10 -0
- package/examples/feature/pie/planet-pie-example-config.json +2 -1
- package/examples/metadata-variables.json +58 -0
- package/package.json +3 -3
- package/src/CdcChart.tsx +8 -4
- package/src/CdcChartComponent.tsx +321 -288
- package/src/_stories/Chart.CustomColors.stories.tsx +74 -0
- package/src/_stories/Chart.Defaults.stories.tsx +95 -0
- package/src/_stories/Chart.SmallestLeftAxisMax.stories.tsx +64 -0
- package/src/_stories/Chart.stories.tsx +36 -2
- package/src/_stories/ChartBar.Editor.stories.tsx +97 -38
- package/src/_stories/ChartBrush.Editor.stories.tsx +11 -25
- package/src/_stories/ChartEditor.Editor.stories.tsx +1 -1
- package/src/_stories/_mock/paired-bar-abbr.json +421 -0
- package/src/_stories/_mock/pie_custom_colors.json +268 -0
- package/src/_stories/_mock/smallest_left_axis_max.json +104 -0
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +10 -10
- package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -1
- package/src/components/Annotations/components/AnnotationList.styles.css +11 -11
- package/src/components/Axis/BottomAxis.tsx +10 -3
- package/src/components/Axis/PairedBarAxis.tsx +10 -4
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +12 -28
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -30
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +12 -31
- package/src/components/BarChart/components/BarChart.Vertical.tsx +12 -28
- package/src/components/BarChart/helpers/getPatternUrl.ts +94 -0
- package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +134 -0
- package/src/components/BarChart/helpers/useBarChart.ts +3 -0
- package/src/components/Brush/BrushSelector.tsx +2 -1
- package/src/components/Brush/MiniChartPreview.tsx +21 -26
- package/src/components/EditorPanel/EditorPanel.tsx +56 -43
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +9 -9
- package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +0 -78
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +39 -1
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +24 -42
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +83 -2
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +45 -42
- package/src/components/EditorPanel/editor-panel.scss +1 -1
- package/src/components/ForestPlot/ForestPlot.tsx +26 -22
- package/src/components/Legend/LegendGroup/LegendGroup.styles.css +4 -4
- package/src/components/Legend/helpers/createFormatLabels.tsx +3 -2
- package/src/components/LinearChart/tests/LinearChart.test.tsx +77 -0
- package/src/components/LinearChart/tests/mockConfigContext.ts +2 -0
- package/src/components/LinearChart.tsx +26 -6
- package/src/components/PieChart/PieChart.tsx +19 -4
- package/src/components/RadarChart/RadarChart.tsx +1 -1
- package/src/components/Regions/components/Regions.tsx +6 -6
- package/src/components/Sankey/components/Sankey.tsx +3 -3
- package/src/components/Sankey/sankey.scss +1 -1
- package/src/components/SmallMultiples/SmallMultiples.css +5 -5
- package/src/components/Sparkline/index.scss +4 -2
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +8 -8
- package/src/data/initial-state.js +23 -14
- package/src/data/legacy-defaults.ts +18 -0
- package/src/helpers/abbreviateNumber.ts +24 -17
- package/src/helpers/getChartPatternId.ts +17 -0
- package/src/helpers/getMinMax.ts +16 -2
- package/src/helpers/seriesColumnSettings.ts +114 -0
- package/src/helpers/tests/countNumOfTicks.test.ts +77 -0
- package/src/helpers/tests/seriesColumnSettings.test.ts +84 -0
- package/src/hooks/useRightAxis.ts +14 -0
- package/src/hooks/useScales.ts +92 -56
- package/src/hooks/useTooltip.tsx +20 -3
- package/src/scss/main.scss +152 -79
- package/src/test/CdcChart.test.jsx +2 -2
- package/src/types/ChartConfig.ts +4 -0
- package/tests/fixtures/chart-config-with-metadata.json +29 -0
- 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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
package/src/helpers/getMinMax.ts
CHANGED
|
@@ -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
|
package/src/hooks/useScales.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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)
|
package/src/hooks/useTooltip.tsx
CHANGED
|
@@ -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 =
|
|
76
|
-
|
|
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]
|
|
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
|
|