@cdc/chart 4.26.6 → 4.26.7
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/CONFIG.md +9 -7
- package/dist/cdcchart.js +32200 -31292
- package/examples/private/flueview.json +22051 -0
- package/package.json +3 -11
- package/src/CdcChart.tsx +2 -2
- package/src/CdcChartComponent.tsx +40 -10
- package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
- package/src/_stories/Chart.smoke.stories.tsx +14 -1
- package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
- package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
- package/src/components/BarChart/helpers/getBarData.ts +28 -28
- package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
- package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
- package/src/components/BarChart/helpers/useBarChart.ts +4 -5
- package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
- package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
- package/src/components/BoxPlot/helpers/index.ts +4 -3
- package/src/components/Brush/BrushSelector.tsx +1 -1
- package/src/components/EditorPanel/EditorPanel.tsx +90 -29
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
- package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +32 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +5 -1
- package/src/components/Forecasting/Forecasting.tsx +1 -1
- package/src/components/Legend/Legend.Component.tsx +54 -59
- package/src/components/Legend/Legend.Suppression.tsx +3 -3
- package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
- package/src/components/LineChart/helpers.ts +0 -2
- package/src/components/LineChart/index.tsx +2 -3
- package/src/components/LinearChart.tsx +4 -3
- package/src/components/PieChart/PieChart.tsx +117 -10
- package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
- package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
- package/src/components/ScatterPlot/ScatterPlot.jsx +2 -10
- package/src/data/initial-state.js +2 -0
- package/src/helpers/categoryOrder.ts +60 -0
- package/src/helpers/getBoxPlotConfig.ts +19 -12
- package/src/helpers/getComboChartConfig.ts +0 -1
- package/src/helpers/getExcludedData.ts +0 -1
- package/src/helpers/getHasBoundarySuppression.ts +4 -3
- package/src/helpers/getMinMax.ts +2 -1
- package/src/helpers/getNewRuntime.ts +0 -2
- package/src/helpers/getPiePercent.ts +3 -3
- package/src/helpers/isConvertLineToBarGraph.ts +2 -2
- package/src/helpers/seriesColumnSettings.ts +21 -3
- package/src/helpers/sizeHelpers.ts +0 -2
- package/src/helpers/tests/categoryOrder.test.ts +57 -0
- package/src/helpers/tests/seriesColumnSettings.test.ts +60 -0
- package/src/hooks/tests/useRightAxis.test.tsx +143 -2
- package/src/hooks/tests/useScales.test.tsx +3 -6
- package/src/hooks/tests/useTooltip.test.tsx +132 -0
- package/src/hooks/useRightAxis.ts +19 -26
- package/src/hooks/useTooltip.tsx +14 -10
- package/src/store/chart.reducer.ts +0 -1
- package/src/test/CdcChart.dataTable.test.tsx +94 -0
|
@@ -2,7 +2,6 @@ import React, { useContext } from 'react'
|
|
|
2
2
|
import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend'
|
|
3
3
|
import LegendShape from '@cdc/core/components/LegendShape'
|
|
4
4
|
import ConfigContext from '../../../ConfigContext'
|
|
5
|
-
import _ from 'lodash'
|
|
6
5
|
import './LegendGroup.styles.css'
|
|
7
6
|
|
|
8
7
|
interface LegendGroup {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { DataItem, StyleProps, Style } from './LineChartProps'
|
|
2
2
|
import { PreliminaryDataItem } from '../../types/ChartConfig'
|
|
3
3
|
|
|
4
|
-
import _ from 'lodash'
|
|
5
|
-
|
|
6
4
|
const isCalculable = value => !isNaN(parseFloat(value)) && isFinite(value)
|
|
7
5
|
|
|
8
6
|
export const createStyles = (props: StyleProps): Style[] => {
|
|
@@ -481,9 +481,7 @@ const LineChart = (props: LineChartProps) => {
|
|
|
481
481
|
break
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
|
-
if (!lastDatum || legend.position === 'right')
|
|
485
|
-
return <></>
|
|
486
|
-
}
|
|
484
|
+
if (!lastDatum || legend.position === 'right') return null
|
|
487
485
|
|
|
488
486
|
let labelText = config.runtime.seriesLabels[seriesKey] || seriesKey
|
|
489
487
|
// truncate labels longer that 10 chars
|
|
@@ -494,6 +492,7 @@ const LineChart = (props: LineChartProps) => {
|
|
|
494
492
|
|
|
495
493
|
return (
|
|
496
494
|
<Text
|
|
495
|
+
key={seriesKey}
|
|
497
496
|
display={
|
|
498
497
|
legend.behavior === 'highlight' ||
|
|
499
498
|
(seriesHighlight.length === 0 && !legend.dynamicLegend) ||
|
|
@@ -144,9 +144,10 @@ const LinearChart = forwardRef<SVGAElement, LinearChartProps>(({ parentHeight, p
|
|
|
144
144
|
fullEligibleDomainData: yAxisDomainData
|
|
145
145
|
})
|
|
146
146
|
const isStableYAxisDomain = getYAxisFilterDomainBehavior(config) === 'stable'
|
|
147
|
-
const rawEligibleYAxisDomainData =
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
const rawEligibleYAxisDomainData =
|
|
148
|
+
Array.isArray(config.yAxisDomainData) && config.yAxisDomainData.length > 0
|
|
149
|
+
? getExcludedData(config, config.yAxisDomainData)
|
|
150
|
+
: isStableYAxisDomain && Array.isArray(excludedData)
|
|
150
151
|
? excludedData
|
|
151
152
|
: tableData
|
|
152
153
|
const suppressionDomainData = getYAxisDomainData({
|
|
@@ -4,7 +4,6 @@ import { animated, useTransition, to } from '@react-spring/web'
|
|
|
4
4
|
// visx
|
|
5
5
|
import { Pie } from '@visx/shape'
|
|
6
6
|
import { Group } from '@visx/group'
|
|
7
|
-
import { Text } from '@visx/text'
|
|
8
7
|
import { useTooltip, TooltipWithBounds } from '@visx/tooltip'
|
|
9
8
|
import { colorPalettesChart as colorPalettes } from '@cdc/core/data/colorPalettes'
|
|
10
9
|
import { getPaletteColors } from '@cdc/core/helpers/palettes/utils'
|
|
@@ -25,6 +24,8 @@ import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
|
|
|
25
24
|
import { scaleOrdinal } from '@visx/scale'
|
|
26
25
|
import { getContrastColor } from '@cdc/core/helpers/cove/accessibility'
|
|
27
26
|
import { type TooltipDisplayData } from '../../helpers/tooltipHelpers'
|
|
27
|
+
import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
|
|
28
|
+
import { getPieLabelPosition } from './helpers/labelPlacement'
|
|
28
29
|
|
|
29
30
|
type TooltipData = TooltipDisplayData
|
|
30
31
|
|
|
@@ -34,6 +35,38 @@ type PieChartProps = {
|
|
|
34
35
|
interactionLabel?: string
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
const ENHANCED_PIE_LABEL_FONT_SIZE = 13
|
|
39
|
+
const ENHANCED_PIE_LABEL_LINE_HEIGHT = 15
|
|
40
|
+
const ENHANCED_PIE_LABEL_HEIGHT = ENHANCED_PIE_LABEL_LINE_HEIGHT * 2
|
|
41
|
+
const ENHANCED_PIE_LABEL_FONT = `${ENHANCED_PIE_LABEL_FONT_SIZE}px sans-serif`
|
|
42
|
+
const ENHANCED_PIE_LABEL_MIN_ANGLE = 0.01
|
|
43
|
+
const LEGACY_PERCENT_LABEL_MIN_ANGLE = 0.1
|
|
44
|
+
const MIN_RADIUS_WITH_LABELS = 55
|
|
45
|
+
const MIN_LABEL_GUTTER = 96
|
|
46
|
+
const OUTSIDE_LABEL_OFFSET = 22
|
|
47
|
+
const LABEL_GUTTER_PADDING = OUTSIDE_LABEL_OFFSET + 16
|
|
48
|
+
const VERTICAL_LABEL_GUTTER = 44
|
|
49
|
+
|
|
50
|
+
const pieLabelTextWidthCache = new Map<string, number>()
|
|
51
|
+
const MAX_PIE_LABEL_TEXT_CACHE = 2000
|
|
52
|
+
|
|
53
|
+
const measurePieLabelText = (text: string) => {
|
|
54
|
+
const cached = pieLabelTextWidthCache.get(text)
|
|
55
|
+
if (cached !== undefined) return cached
|
|
56
|
+
|
|
57
|
+
const measured =
|
|
58
|
+
typeof document === 'undefined'
|
|
59
|
+
? Math.ceil(text.length * ENHANCED_PIE_LABEL_FONT_SIZE * 0.6)
|
|
60
|
+
: (getTextWidth(text, ENHANCED_PIE_LABEL_FONT) ??
|
|
61
|
+
Math.ceil(text.length * ENHANCED_PIE_LABEL_FONT_SIZE * 0.6))
|
|
62
|
+
|
|
63
|
+
// Avoid unbounded growth in long-lived sessions with many unique labels.
|
|
64
|
+
if (pieLabelTextWidthCache.size >= MAX_PIE_LABEL_TEXT_CACHE) pieLabelTextWidthCache.clear()
|
|
65
|
+
|
|
66
|
+
pieLabelTextWidthCache.set(text, measured)
|
|
67
|
+
return measured
|
|
68
|
+
}
|
|
69
|
+
|
|
37
70
|
const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) => {
|
|
38
71
|
const { interactionLabel = '' } = props
|
|
39
72
|
const {
|
|
@@ -70,6 +103,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
70
103
|
const pivotKey = dataNeedsPivot ? 'pivotColumn' : undefined
|
|
71
104
|
const showPercentage = config.dataFormat.showPiePercent
|
|
72
105
|
const labelForCalcArea = 'Calculated Area'
|
|
106
|
+
const showCategoryPercentageLabels = Boolean(config.labels)
|
|
73
107
|
|
|
74
108
|
const _data = useMemo(() => {
|
|
75
109
|
let baseData = []
|
|
@@ -269,9 +303,25 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
269
303
|
|
|
270
304
|
let roundedPercentage = percentageToDisplay.toFixed(roundTo) + '%'
|
|
271
305
|
// add missing pie part
|
|
272
|
-
if (arc.data[config.xAxis.dataKey] === labelForCalcArea && config.dataFormat.showPiePercent) {
|
|
306
|
+
if (arc.data[config.runtime.xAxis.dataKey] === labelForCalcArea && config.dataFormat.showPiePercent) {
|
|
273
307
|
roundedPercentage = '**'
|
|
274
308
|
}
|
|
309
|
+
let categoryLabel = ''
|
|
310
|
+
let labelPosition = { placement: 'inside' as const, x: 0, y: 0, textAnchor: 'middle' as const }
|
|
311
|
+
|
|
312
|
+
if (showCategoryPercentageLabels) {
|
|
313
|
+
categoryLabel = String(arc.data[config.runtime.xAxis.dataKey] ?? '')
|
|
314
|
+
const labelWidth = Math.max(measurePieLabelText(categoryLabel), measurePieLabelText(roundedPercentage))
|
|
315
|
+
labelPosition = getPieLabelPosition({
|
|
316
|
+
startAngle: arc.startAngle,
|
|
317
|
+
endAngle: arc.endAngle,
|
|
318
|
+
innerRadius: radius - donutThickness,
|
|
319
|
+
outerRadius: radius,
|
|
320
|
+
labelWidth,
|
|
321
|
+
labelHeight: ENHANCED_PIE_LABEL_HEIGHT,
|
|
322
|
+
isDonut: config.pieType === 'Donut'
|
|
323
|
+
})
|
|
324
|
+
}
|
|
275
325
|
|
|
276
326
|
// Determine if this slice should be muted based on legend behavior
|
|
277
327
|
const isHighlighted =
|
|
@@ -279,6 +329,8 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
279
329
|
const shouldMute = config.legend.behavior === 'highlight' && seriesHighlight.length > 0 && !isHighlighted
|
|
280
330
|
const sliceOpacity = shouldMute ? 0.3 : 1
|
|
281
331
|
const textOpacity = shouldMute ? 0.3 : 1
|
|
332
|
+
const labelFill = labelPosition.placement === 'inside' ? textColor : '#000000'
|
|
333
|
+
const labelMinAngle = showCategoryPercentageLabels ? ENHANCED_PIE_LABEL_MIN_ANGLE : LEGACY_PERCENT_LABEL_MIN_ANGLE
|
|
282
334
|
|
|
283
335
|
return (
|
|
284
336
|
<Group key={key} className={`slice-${CSS.escape(String(key))}`}>
|
|
@@ -302,7 +354,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
302
354
|
/>
|
|
303
355
|
|
|
304
356
|
{/* ── the percentage label */}
|
|
305
|
-
{arc.endAngle - arc.startAngle >
|
|
357
|
+
{arc.endAngle - arc.startAngle > labelMinAngle && !showCategoryPercentageLabels && (
|
|
306
358
|
<animated.text
|
|
307
359
|
transform={to([styles.startAngle, styles.endAngle], (start: number, end: number) => {
|
|
308
360
|
const [x, y] = path.centroid({
|
|
@@ -321,6 +373,37 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
321
373
|
{roundedPercentage}
|
|
322
374
|
</animated.text>
|
|
323
375
|
)}
|
|
376
|
+
|
|
377
|
+
{arc.endAngle - arc.startAngle > labelMinAngle && showCategoryPercentageLabels && (
|
|
378
|
+
<animated.text
|
|
379
|
+
className={`pie-label pie-label--${labelPosition.placement}`}
|
|
380
|
+
transform={to([styles.startAngle, styles.endAngle], (start: number, end: number) => {
|
|
381
|
+
const midAngle = start + (end - start) / 2
|
|
382
|
+
const innerRadius = radius - donutThickness
|
|
383
|
+
const outsideOffset = OUTSIDE_LABEL_OFFSET
|
|
384
|
+
const labelRadius =
|
|
385
|
+
labelPosition.placement === 'inside'
|
|
386
|
+
? innerRadius + (radius - innerRadius) * 0.62
|
|
387
|
+
: radius + outsideOffset
|
|
388
|
+
const x = Math.sin(midAngle) * labelRadius
|
|
389
|
+
const y = -Math.cos(midAngle) * labelRadius
|
|
390
|
+
return `translate(${x},${y})`
|
|
391
|
+
})}
|
|
392
|
+
textAnchor={labelPosition.textAnchor}
|
|
393
|
+
pointerEvents='none'
|
|
394
|
+
fill={labelFill}
|
|
395
|
+
opacity={textOpacity}
|
|
396
|
+
style={{ fontSize: ENHANCED_PIE_LABEL_FONT_SIZE, fontFamily: 'sans-serif' }}
|
|
397
|
+
>
|
|
398
|
+
<title>{`${categoryLabel}: ${roundedPercentage}`}</title>
|
|
399
|
+
<tspan x={0} dy={-ENHANCED_PIE_LABEL_LINE_HEIGHT / 2}>
|
|
400
|
+
{categoryLabel}
|
|
401
|
+
</tspan>
|
|
402
|
+
<tspan x={0} dy={ENHANCED_PIE_LABEL_LINE_HEIGHT}>
|
|
403
|
+
{roundedPercentage}
|
|
404
|
+
</tspan>
|
|
405
|
+
</animated.text>
|
|
406
|
+
)}
|
|
324
407
|
</Group>
|
|
325
408
|
)
|
|
326
409
|
})
|
|
@@ -334,11 +417,35 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
334
417
|
}
|
|
335
418
|
|
|
336
419
|
const height = config.heights.vertical
|
|
337
|
-
|
|
338
|
-
|
|
420
|
+
const maxPieLabelWidth = useMemo(() => {
|
|
421
|
+
if (!showCategoryPercentageLabels) return 0
|
|
422
|
+
|
|
423
|
+
const roundTo = Number(config.dataFormat.roundTo) || 0
|
|
424
|
+
const percentExample = `${(100).toFixed(roundTo)}%`
|
|
425
|
+
|
|
426
|
+
return _data.reduce((widestLabel, datum) => {
|
|
427
|
+
const categoryLabel = String(datum[config.runtime.xAxis.dataKey] ?? '')
|
|
428
|
+
return Math.max(widestLabel, measurePieLabelText(categoryLabel), measurePieLabelText(percentExample))
|
|
429
|
+
}, 0)
|
|
430
|
+
}, [_data, config.dataFormat.roundTo, config.runtime.xAxis.dataKey, showCategoryPercentageLabels])
|
|
431
|
+
const maxHorizontalLabelGutter = showCategoryPercentageLabels
|
|
432
|
+
? Math.max(0, (width - MIN_RADIUS_WITH_LABELS * 2) / 2)
|
|
433
|
+
: 0
|
|
434
|
+
const labelGutter = showCategoryPercentageLabels
|
|
435
|
+
? Math.min(Math.max(maxPieLabelWidth + LABEL_GUTTER_PADDING, MIN_LABEL_GUTTER), maxHorizontalLabelGutter)
|
|
436
|
+
: 0
|
|
437
|
+
const availableDiameter = showCategoryPercentageLabels
|
|
438
|
+
? Math.min(width - labelGutter * 2, height - VERTICAL_LABEL_GUTTER * 2)
|
|
439
|
+
: Math.min(width, height)
|
|
440
|
+
|
|
441
|
+
const radius = showCategoryPercentageLabels
|
|
442
|
+
? Math.max(0, availableDiameter / 2)
|
|
443
|
+
: Math.min(width, height) / 2
|
|
444
|
+
const svgWidth = showCategoryPercentageLabels ? width : radius * 2
|
|
445
|
+
const svgLeftOffset = Math.max((props.parentWidth - svgWidth) / 2, 0)
|
|
339
446
|
const centerY = height / 2
|
|
340
|
-
const centerX =
|
|
341
|
-
const donutThickness = config.pieType === 'Donut' ? 75 : radius
|
|
447
|
+
const centerX = showCategoryPercentageLabels ? svgWidth / 2 : radius
|
|
448
|
+
const donutThickness = config.pieType === 'Donut' ? Math.min(75, radius * 0.65) : radius
|
|
342
449
|
|
|
343
450
|
useEffect(() => {
|
|
344
451
|
if (seriesHighlight.length > 0 && config.legend.behavior !== 'highlight') {
|
|
@@ -389,7 +496,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
389
496
|
<>
|
|
390
497
|
<ErrorBoundary component='PieChart'>
|
|
391
498
|
<svg
|
|
392
|
-
width={
|
|
499
|
+
width={svgWidth}
|
|
393
500
|
height={height}
|
|
394
501
|
className={getSvgClasses()}
|
|
395
502
|
role='img'
|
|
@@ -400,7 +507,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
400
507
|
handleChartMouseLeave()
|
|
401
508
|
}}
|
|
402
509
|
>
|
|
403
|
-
<Group top={centerY} left={
|
|
510
|
+
<Group top={centerY} left={centerX}>
|
|
404
511
|
{/* prettier-ignore */}
|
|
405
512
|
<Pie
|
|
406
513
|
data={filteredData || _data}
|
|
@@ -435,7 +542,7 @@ const PieChart = React.forwardRef<SVGSVGElement, PieChartProps>((props, ref) =>
|
|
|
435
542
|
}) !important`}</style>
|
|
436
543
|
<TooltipWithBounds
|
|
437
544
|
className={'tooltip cove-visualization'}
|
|
438
|
-
left={tooltipLeft +
|
|
545
|
+
left={tooltipLeft + svgLeftOffset}
|
|
439
546
|
top={tooltipTop}
|
|
440
547
|
>
|
|
441
548
|
<ul>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getPieLabelPlacement, getPieLabelPosition } from './labelPlacement'
|
|
3
|
+
|
|
4
|
+
describe('pie label placement', () => {
|
|
5
|
+
it('keeps labels inside regular pie slices when the slice has enough room', () => {
|
|
6
|
+
expect(
|
|
7
|
+
getPieLabelPlacement({
|
|
8
|
+
startAngle: 0,
|
|
9
|
+
endAngle: Math.PI / 2,
|
|
10
|
+
innerRadius: 0,
|
|
11
|
+
outerRadius: 120,
|
|
12
|
+
labelWidth: 50,
|
|
13
|
+
labelHeight: 28,
|
|
14
|
+
isDonut: false
|
|
15
|
+
})
|
|
16
|
+
).toBe('inside')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('moves regular pie labels outside when they are too wide for the slice', () => {
|
|
20
|
+
expect(
|
|
21
|
+
getPieLabelPlacement({
|
|
22
|
+
startAngle: 0,
|
|
23
|
+
endAngle: Math.PI / 8,
|
|
24
|
+
innerRadius: 0,
|
|
25
|
+
outerRadius: 120,
|
|
26
|
+
labelWidth: 80,
|
|
27
|
+
labelHeight: 28,
|
|
28
|
+
isDonut: false
|
|
29
|
+
})
|
|
30
|
+
).toBe('outside')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('always places donut labels outside the partition', () => {
|
|
34
|
+
expect(
|
|
35
|
+
getPieLabelPlacement({
|
|
36
|
+
startAngle: 0,
|
|
37
|
+
endAngle: Math.PI,
|
|
38
|
+
innerRadius: 60,
|
|
39
|
+
outerRadius: 120,
|
|
40
|
+
labelWidth: 20,
|
|
41
|
+
labelHeight: 20,
|
|
42
|
+
isDonut: true
|
|
43
|
+
})
|
|
44
|
+
).toBe('outside')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('anchors outside labels by side of the pie', () => {
|
|
48
|
+
const rightSide = getPieLabelPosition({
|
|
49
|
+
startAngle: 0,
|
|
50
|
+
endAngle: Math.PI / 8,
|
|
51
|
+
innerRadius: 0,
|
|
52
|
+
outerRadius: 120,
|
|
53
|
+
labelWidth: 80,
|
|
54
|
+
labelHeight: 28,
|
|
55
|
+
isDonut: false
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const leftSide = getPieLabelPosition({
|
|
59
|
+
startAngle: Math.PI,
|
|
60
|
+
endAngle: Math.PI + Math.PI / 8,
|
|
61
|
+
innerRadius: 0,
|
|
62
|
+
outerRadius: 120,
|
|
63
|
+
labelWidth: 80,
|
|
64
|
+
labelHeight: 28,
|
|
65
|
+
isDonut: false
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(rightSide.placement).toBe('outside')
|
|
69
|
+
expect(rightSide.textAnchor).toBe('start')
|
|
70
|
+
expect(leftSide.placement).toBe('outside')
|
|
71
|
+
expect(leftSide.textAnchor).toBe('end')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('uses the same angle orientation as the arc generator', () => {
|
|
75
|
+
const topLabel = getPieLabelPosition({
|
|
76
|
+
startAngle: 0,
|
|
77
|
+
endAngle: Math.PI / 12,
|
|
78
|
+
innerRadius: 0,
|
|
79
|
+
outerRadius: 120,
|
|
80
|
+
labelWidth: 80,
|
|
81
|
+
labelHeight: 28,
|
|
82
|
+
isDonut: false
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(topLabel.placement).toBe('outside')
|
|
86
|
+
expect(topLabel.y).toBeLessThan(0)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type PieLabelPlacement = 'inside' | 'outside'
|
|
2
|
+
|
|
3
|
+
export type PieLabelAnchor = 'start' | 'middle' | 'end'
|
|
4
|
+
|
|
5
|
+
export type PieLabelPosition = {
|
|
6
|
+
placement: PieLabelPlacement
|
|
7
|
+
x: number
|
|
8
|
+
y: number
|
|
9
|
+
textAnchor: PieLabelAnchor
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type PieLabelPlacementOptions = {
|
|
13
|
+
startAngle: number
|
|
14
|
+
endAngle: number
|
|
15
|
+
innerRadius: number
|
|
16
|
+
outerRadius: number
|
|
17
|
+
labelWidth: number
|
|
18
|
+
labelHeight: number
|
|
19
|
+
isDonut: boolean
|
|
20
|
+
insidePadding?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type PieLabelPositionOptions = PieLabelPlacementOptions & {
|
|
24
|
+
outsideOffset?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_INSIDE_PADDING = 12
|
|
28
|
+
const DEFAULT_OUTSIDE_OFFSET = 22
|
|
29
|
+
|
|
30
|
+
const getMidAngle = (startAngle: number, endAngle: number) => startAngle + (endAngle - startAngle) / 2
|
|
31
|
+
const getArcPoint = (angle: number, radius: number) => ({
|
|
32
|
+
x: Math.sin(angle) * radius,
|
|
33
|
+
y: -Math.cos(angle) * radius
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const getPieLabelPlacement = ({
|
|
37
|
+
startAngle,
|
|
38
|
+
endAngle,
|
|
39
|
+
innerRadius,
|
|
40
|
+
outerRadius,
|
|
41
|
+
labelWidth,
|
|
42
|
+
labelHeight,
|
|
43
|
+
isDonut,
|
|
44
|
+
insidePadding = DEFAULT_INSIDE_PADDING
|
|
45
|
+
}: PieLabelPlacementOptions): PieLabelPlacement => {
|
|
46
|
+
if (isDonut) return 'outside'
|
|
47
|
+
|
|
48
|
+
const angle = Math.abs(endAngle - startAngle)
|
|
49
|
+
const labelRadius = innerRadius + (outerRadius - innerRadius) * 0.62
|
|
50
|
+
const availableChord = 2 * labelRadius * Math.sin(angle / 2)
|
|
51
|
+
const availableRadialDepth = outerRadius - innerRadius
|
|
52
|
+
|
|
53
|
+
const hasEnoughArcWidth = labelWidth + insidePadding <= availableChord
|
|
54
|
+
const hasEnoughRadialDepth = labelHeight + insidePadding <= availableRadialDepth
|
|
55
|
+
|
|
56
|
+
return hasEnoughArcWidth && hasEnoughRadialDepth ? 'inside' : 'outside'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const getPieLabelPosition = ({
|
|
60
|
+
startAngle,
|
|
61
|
+
endAngle,
|
|
62
|
+
innerRadius,
|
|
63
|
+
outerRadius,
|
|
64
|
+
labelWidth,
|
|
65
|
+
labelHeight,
|
|
66
|
+
isDonut,
|
|
67
|
+
insidePadding,
|
|
68
|
+
outsideOffset = DEFAULT_OUTSIDE_OFFSET
|
|
69
|
+
}: PieLabelPositionOptions): PieLabelPosition => {
|
|
70
|
+
const placement = getPieLabelPlacement({
|
|
71
|
+
startAngle,
|
|
72
|
+
endAngle,
|
|
73
|
+
innerRadius,
|
|
74
|
+
outerRadius,
|
|
75
|
+
labelWidth,
|
|
76
|
+
labelHeight,
|
|
77
|
+
isDonut,
|
|
78
|
+
insidePadding
|
|
79
|
+
})
|
|
80
|
+
const midAngle = getMidAngle(startAngle, endAngle)
|
|
81
|
+
const labelRadius =
|
|
82
|
+
placement === 'inside' ? innerRadius + (outerRadius - innerRadius) * 0.62 : outerRadius + outsideOffset
|
|
83
|
+
const { x, y } = getArcPoint(midAngle, labelRadius)
|
|
84
|
+
|
|
85
|
+
if (placement === 'inside') {
|
|
86
|
+
return { placement, x, y, textAnchor: 'middle' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
placement,
|
|
91
|
+
x,
|
|
92
|
+
y,
|
|
93
|
+
textAnchor: x < 0 ? 'end' : 'start'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -5,6 +5,7 @@ import { formatNumber as formatColNumber } from '@cdc/core/helpers/cove/number'
|
|
|
5
5
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
6
6
|
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
7
7
|
import { buildSeriesTooltipListHtml } from '../../helpers/tooltipHelpers'
|
|
8
|
+
import { getSeriesColumnFormattingParams } from '../../helpers/seriesColumnSettings'
|
|
8
9
|
|
|
9
10
|
const ScatterPlot = ({ xScale, yScale, yAxisWidth, getXAxisData }) => {
|
|
10
11
|
const {
|
|
@@ -25,16 +26,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth, getXAxisData }) => {
|
|
|
25
26
|
// tooltips for additional columns
|
|
26
27
|
const additionalColumns = Object.entries(config.columns)
|
|
27
28
|
.filter(([_, value]) => value.tooltips)
|
|
28
|
-
.map(([_, value]) => [
|
|
29
|
-
value.label || value.name,
|
|
30
|
-
value.name,
|
|
31
|
-
{
|
|
32
|
-
addColPrefix: value.prefix,
|
|
33
|
-
addColSuffix: value.suffix,
|
|
34
|
-
addColRoundTo: value.roundToPlace,
|
|
35
|
-
addColCommas: value.commas
|
|
36
|
-
}
|
|
37
|
-
])
|
|
29
|
+
.map(([_, value]) => [value.label || value.name, value.name, getSeriesColumnFormattingParams(value)])
|
|
38
30
|
const handleTooltip = (item, s, dataIndex) => `<div>
|
|
39
31
|
${buildSeriesTooltipListHtml({
|
|
40
32
|
config,
|
|
@@ -144,6 +144,8 @@ const createInitialState = () => {
|
|
|
144
144
|
padding: 5,
|
|
145
145
|
showYearsOnce: false,
|
|
146
146
|
sortByRecentDate: false,
|
|
147
|
+
categoryOrder: [],
|
|
148
|
+
categoryOrderType: 'data',
|
|
147
149
|
brushActive: false,
|
|
148
150
|
brushDefaultRecentDateCount: undefined,
|
|
149
151
|
brushDynamicYAxis: false,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ChartConfig } from '../types/ChartConfig'
|
|
2
|
+
|
|
3
|
+
type DataRow = Record<string, any>
|
|
4
|
+
|
|
5
|
+
export const isCustomCategoryOrderActive = (config: Pick<ChartConfig, 'xAxis'>) => {
|
|
6
|
+
return (
|
|
7
|
+
config.xAxis?.type === 'categorical' &&
|
|
8
|
+
config.xAxis?.categoryOrderType === 'custom' &&
|
|
9
|
+
Array.isArray(config.xAxis?.categoryOrder) &&
|
|
10
|
+
config.xAxis.categoryOrder.length > 0 &&
|
|
11
|
+
Boolean(config.xAxis?.dataKey)
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const getCategoryValues = (data: DataRow[] = [], dataKey?: string): string[] => {
|
|
16
|
+
if (!dataKey || !Array.isArray(data)) return []
|
|
17
|
+
|
|
18
|
+
const values: string[] = []
|
|
19
|
+
for (const row of data) {
|
|
20
|
+
const value = String(row?.[dataKey] ?? '')
|
|
21
|
+
if (value !== '') values.push(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return Array.from(new Set(values))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const getOrderedCategoryValues = (
|
|
28
|
+
data: DataRow[] = [],
|
|
29
|
+
dataKey?: string,
|
|
30
|
+
categoryOrder: string[] = []
|
|
31
|
+
): string[] => {
|
|
32
|
+
const values = getCategoryValues(data, dataKey)
|
|
33
|
+
if (!categoryOrder?.length) return values
|
|
34
|
+
|
|
35
|
+
const configuredValues = categoryOrder.map(String)
|
|
36
|
+
const configuredValueSet = new Set(configuredValues)
|
|
37
|
+
const valueSet = new Set(values)
|
|
38
|
+
const extras = values.filter(value => !configuredValueSet.has(value))
|
|
39
|
+
|
|
40
|
+
return [...configuredValues.filter(value => valueSet.has(value)), ...extras]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sortByCategoryOrder = (data: DataRow[] = [], config: Pick<ChartConfig, 'xAxis'>): DataRow[] => {
|
|
44
|
+
if (!Array.isArray(data) || !isCustomCategoryOrderActive(config)) return data
|
|
45
|
+
|
|
46
|
+
const dataKey = config.xAxis.dataKey
|
|
47
|
+
const categoryOrder = Array.from(new Set((config.xAxis.categoryOrder || []).map(String)))
|
|
48
|
+
const indexByValue = new Map(categoryOrder.map((value, index) => [value, index]))
|
|
49
|
+
return data
|
|
50
|
+
.map((row, originalIndex) => ({ row, originalIndex }))
|
|
51
|
+
.sort((a, b) => {
|
|
52
|
+
const indexA = indexByValue.get(String(a.row?.[dataKey] ?? ''))
|
|
53
|
+
const indexB = indexByValue.get(String(b.row?.[dataKey] ?? ''))
|
|
54
|
+
const rankA = indexA ?? Number.MAX_SAFE_INTEGER
|
|
55
|
+
const rankB = indexB ?? Number.MAX_SAFE_INTEGER
|
|
56
|
+
|
|
57
|
+
return rankA === rankB ? a.originalIndex - b.originalIndex : rankA - rankB
|
|
58
|
+
})
|
|
59
|
+
.map(({ row }) => row)
|
|
60
|
+
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import capitalize from 'lodash/capitalize'
|
|
2
|
+
import chain from 'lodash/chain'
|
|
3
|
+
import filter from 'lodash/filter'
|
|
4
|
+
import flatMap from 'lodash/flatMap'
|
|
5
|
+
import map from 'lodash/map'
|
|
6
|
+
import min from 'lodash/min'
|
|
7
|
+
import round from 'lodash/round'
|
|
8
|
+
import uniq from 'lodash/uniq'
|
|
2
9
|
import { ChartConfig } from '../types/ChartConfig'
|
|
3
10
|
import * as d3 from 'd3-array'
|
|
4
11
|
|
|
5
12
|
export const getBoxPlotConfig = (newConfig: ChartConfig, data: object[]) => {
|
|
6
13
|
const combinedData = data
|
|
7
|
-
const groups =
|
|
8
|
-
const seriesKeys =
|
|
14
|
+
const groups = uniq(map(combinedData, newConfig.xAxis.dataKey))
|
|
15
|
+
const seriesKeys = map(newConfig.series, 'dataKey')
|
|
9
16
|
const plots: any[] = []
|
|
10
17
|
|
|
11
18
|
groups.forEach(g => {
|
|
@@ -14,13 +21,13 @@ export const getBoxPlotConfig = (newConfig: ChartConfig, data: object[]) => {
|
|
|
14
21
|
if (!g) throw new Error('No groups resolved in box plots')
|
|
15
22
|
|
|
16
23
|
// Start handle operations on combinedData
|
|
17
|
-
const { count, sortedData } =
|
|
24
|
+
const { count, sortedData } = chain(combinedData)
|
|
18
25
|
// Filter by xAxis data key
|
|
19
26
|
.filter(item => item[newConfig.xAxis.dataKey] === g)
|
|
20
27
|
// perform multiple operations on the filtered data
|
|
21
28
|
.thru(filteredData => ({
|
|
22
29
|
count: filteredData.length,
|
|
23
|
-
sortedData:
|
|
30
|
+
sortedData: map(filteredData, item => Number(item[seriesKey])).sort()
|
|
24
31
|
}))
|
|
25
32
|
// get the results from the chain
|
|
26
33
|
.value()
|
|
@@ -38,19 +45,19 @@ export const getBoxPlotConfig = (newConfig: ChartConfig, data: object[]) => {
|
|
|
38
45
|
plots.push({
|
|
39
46
|
columnCategory: g,
|
|
40
47
|
columnMax: d3.max(nonOutliers),
|
|
41
|
-
columnThirdQuartile:
|
|
48
|
+
columnThirdQuartile: round(q3, newConfig.dataFormat.roundTo),
|
|
42
49
|
columnMedian: Number(d3.median(sortedData)).toFixed(newConfig.dataFormat.roundTo),
|
|
43
|
-
columnFirstQuartile:
|
|
44
|
-
columnMin:
|
|
50
|
+
columnFirstQuartile: round(q1, newConfig.dataFormat.roundTo),
|
|
51
|
+
columnMin: min(nonOutliers),
|
|
45
52
|
columnCount: count,
|
|
46
53
|
columnSd: Number(d3.deviation(sortedData)).toFixed(newConfig.dataFormat.roundTo),
|
|
47
54
|
columnMean: Number(d3.mean(sortedData)).toFixed(newConfig.dataFormat.roundTo),
|
|
48
|
-
columnIqr:
|
|
55
|
+
columnIqr: round(iqr, newConfig.dataFormat.roundTo),
|
|
49
56
|
values: sortedData,
|
|
50
57
|
columnLowerBounds: lowerBounds,
|
|
51
58
|
columnUpperBounds: upperBounds,
|
|
52
|
-
columnOutliers:
|
|
53
|
-
columnNonOutliers:
|
|
59
|
+
columnOutliers: filter(sortedData, value => value < lowerBounds || value > upperBounds),
|
|
60
|
+
columnNonOutliers: filter(sortedData, value => value >= lowerBounds && value <= upperBounds)
|
|
54
61
|
})
|
|
55
62
|
} catch (e) {
|
|
56
63
|
console.error('COVE: ', e.message) // eslint-disable-line
|
|
@@ -61,7 +68,7 @@ export const getBoxPlotConfig = (newConfig: ChartConfig, data: object[]) => {
|
|
|
61
68
|
// Generate a flat list of categories based on seriesKeys and groups
|
|
62
69
|
const categories =
|
|
63
70
|
seriesKeys.length > 1
|
|
64
|
-
?
|
|
71
|
+
? flatMap(groups, value => map(seriesKeys, key => `${capitalize(key)} - ${capitalize(value)}`))
|
|
65
72
|
: groups
|
|
66
73
|
|
|
67
74
|
return [plots, categories]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import pick from 'lodash/pick'
|
|
2
|
+
import values from 'lodash/values'
|
|
2
3
|
import { ChartConfig } from '../types/ChartConfig'
|
|
3
4
|
|
|
4
5
|
type GetHasBoundarySuppressionProps = {
|
|
@@ -15,9 +16,9 @@ export const getHasBoundarySuppression = ({ rows, config }: GetHasBoundarySuppre
|
|
|
15
16
|
|
|
16
17
|
return boundaryRows.some(item =>
|
|
17
18
|
suppressionRules.some(({ column, value }) => {
|
|
18
|
-
const
|
|
19
|
+
const seriesValues = values(pick(item, config.runtime?.seriesKeys))
|
|
19
20
|
const dynamicCategory = config.series[0]?.dynamicCategory
|
|
20
|
-
const match = column ? item[column] === value :
|
|
21
|
+
const match = column ? item[column] === value : seriesValues.includes(value)
|
|
21
22
|
const dynamic = dynamicCategory && (item[dynamicCategory] === column || !column)
|
|
22
23
|
|
|
23
24
|
return Boolean(match || dynamic)
|
package/src/helpers/getMinMax.ts
CHANGED
|
@@ -190,7 +190,8 @@ const getMinMax = ({
|
|
|
190
190
|
|
|
191
191
|
const isCategorical = config.yAxis.type === 'categorical'
|
|
192
192
|
|
|
193
|
-
min =
|
|
193
|
+
min =
|
|
194
|
+
enteredMinValue !== '' && isMinValid ? numEnteredMin : hasBoundarySuppression ? 0 : isCategorical ? 0 : minValue
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
//If data value max wasn't provided, calculate it
|