@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.
Files changed (60) hide show
  1. package/CONFIG.md +9 -7
  2. package/dist/cdcchart.js +32200 -31292
  3. package/examples/private/flueview.json +22051 -0
  4. package/package.json +3 -11
  5. package/src/CdcChart.tsx +2 -2
  6. package/src/CdcChartComponent.tsx +40 -10
  7. package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
  8. package/src/_stories/Chart.smoke.stories.tsx +14 -1
  9. package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
  10. package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
  11. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
  12. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
  13. package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
  14. package/src/components/BarChart/helpers/getBarData.ts +28 -28
  15. package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
  16. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
  17. package/src/components/BarChart/helpers/useBarChart.ts +4 -5
  18. package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
  19. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
  20. package/src/components/BoxPlot/helpers/index.ts +4 -3
  21. package/src/components/Brush/BrushSelector.tsx +1 -1
  22. package/src/components/EditorPanel/EditorPanel.tsx +90 -29
  23. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
  24. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
  25. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
  26. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
  27. package/src/components/EditorPanel/useEditorPermissions.test.tsx +32 -0
  28. package/src/components/EditorPanel/useEditorPermissions.ts +5 -1
  29. package/src/components/Forecasting/Forecasting.tsx +1 -1
  30. package/src/components/Legend/Legend.Component.tsx +54 -59
  31. package/src/components/Legend/Legend.Suppression.tsx +3 -3
  32. package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
  33. package/src/components/LineChart/helpers.ts +0 -2
  34. package/src/components/LineChart/index.tsx +2 -3
  35. package/src/components/LinearChart.tsx +4 -3
  36. package/src/components/PieChart/PieChart.tsx +117 -10
  37. package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
  38. package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
  39. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -10
  40. package/src/data/initial-state.js +2 -0
  41. package/src/helpers/categoryOrder.ts +60 -0
  42. package/src/helpers/getBoxPlotConfig.ts +19 -12
  43. package/src/helpers/getComboChartConfig.ts +0 -1
  44. package/src/helpers/getExcludedData.ts +0 -1
  45. package/src/helpers/getHasBoundarySuppression.ts +4 -3
  46. package/src/helpers/getMinMax.ts +2 -1
  47. package/src/helpers/getNewRuntime.ts +0 -2
  48. package/src/helpers/getPiePercent.ts +3 -3
  49. package/src/helpers/isConvertLineToBarGraph.ts +2 -2
  50. package/src/helpers/seriesColumnSettings.ts +21 -3
  51. package/src/helpers/sizeHelpers.ts +0 -2
  52. package/src/helpers/tests/categoryOrder.test.ts +57 -0
  53. package/src/helpers/tests/seriesColumnSettings.test.ts +60 -0
  54. package/src/hooks/tests/useRightAxis.test.tsx +143 -2
  55. package/src/hooks/tests/useScales.test.tsx +3 -6
  56. package/src/hooks/tests/useTooltip.test.tsx +132 -0
  57. package/src/hooks/useRightAxis.ts +19 -26
  58. package/src/hooks/useTooltip.tsx +14 -10
  59. package/src/store/chart.reducer.ts +0 -1
  60. 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 = Array.isArray(config.yAxisDomainData) && config.yAxisDomainData.length > 0
148
- ? getExcludedData(config, config.yAxisDomainData)
149
- : isStableYAxisDomain && Array.isArray(excludedData)
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 > 0.1 && (
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
- const radius = Math.min(width, height) / 2
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 = props.parentWidth / 2
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={radius * 2}
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={radius}>
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 + centerX - radius}
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 _ from 'lodash'
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 = _.uniq(_.map(combinedData, newConfig.xAxis.dataKey))
8
- const seriesKeys = _.map(newConfig.series, 'dataKey')
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 } = _.chain(combinedData)
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: _.map(filteredData, item => Number(item[seriesKey])).sort()
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: _.round(q3, newConfig.dataFormat.roundTo),
48
+ columnThirdQuartile: round(q3, newConfig.dataFormat.roundTo),
42
49
  columnMedian: Number(d3.median(sortedData)).toFixed(newConfig.dataFormat.roundTo),
43
- columnFirstQuartile: _.round(q1, newConfig.dataFormat.roundTo),
44
- columnMin: _.min(nonOutliers),
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: _.round(iqr, newConfig.dataFormat.roundTo),
55
+ columnIqr: round(iqr, newConfig.dataFormat.roundTo),
49
56
  values: sortedData,
50
57
  columnLowerBounds: lowerBounds,
51
58
  columnUpperBounds: upperBounds,
52
- columnOutliers: _.filter(sortedData, value => value < lowerBounds || value > upperBounds),
53
- columnNonOutliers: _.filter(sortedData, value => value >= lowerBounds && value <= upperBounds)
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
- ? _.flatMap(groups, value => _.map(seriesKeys, key => `${_.capitalize(key)} - ${_.capitalize(value)}`))
71
+ ? flatMap(groups, value => map(seriesKeys, key => `${capitalize(key)} - ${capitalize(value)}`))
65
72
  : groups
66
73
 
67
74
  return [plots, categories]
@@ -1,4 +1,3 @@
1
- import _ from 'lodash'
2
1
  import { ChartConfig } from '../types/ChartConfig'
3
2
  import * as d3 from 'd3-array'
4
3
 
@@ -1,5 +1,4 @@
1
1
  import { isDateScale } from '@cdc/core/helpers/cove/date'
2
- import _ from 'lodash'
3
2
  import { ChartConfig } from '../types/ChartConfig'
4
3
  export const getExcludedData = (newConfig: ChartConfig, data: object[]) => {
5
4
  if (!Array.isArray(data)) {
@@ -1,4 +1,5 @@
1
- import _ from 'lodash'
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 values = _.values(_.pick(item, config.runtime?.seriesKeys))
19
+ const seriesValues = values(pick(item, config.runtime?.seriesKeys))
19
20
  const dynamicCategory = config.series[0]?.dynamicCategory
20
- const match = column ? item[column] === value : values.includes(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)
@@ -190,7 +190,8 @@ const getMinMax = ({
190
190
 
191
191
  const isCategorical = config.yAxis.type === 'categorical'
192
192
 
193
- min = enteredMinValue !== '' && isMinValid ? numEnteredMin : hasBoundarySuppression ? 0 : isCategorical ? 0 : minValue
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
@@ -1,5 +1,3 @@
1
- import _ from 'lodash'
2
-
3
1
  export const getNewRuntime = (visualizationConfig, newFilteredData) => {
4
2
  const runtime = visualizationConfig.runtime ? { ...visualizationConfig.runtime } : {}
5
3
  runtime.series = []