@cdc/chart 4.24.2 → 4.24.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/dist/cdcchart.js +47386 -36618
- package/examples/chart-regression-1.json +378 -0
- package/examples/chart-regression-2.json +2360 -0
- package/examples/feature/filters/url-filter.json +1076 -0
- package/examples/feature/line/line-chart.json +2 -1
- package/examples/feature/regions/index.json +50 -4
- package/examples/feature/sankey/sankey-example-data.json +1364 -0
- package/examples/feature/sankey/sankey_chart_data.csv +20 -0
- package/examples/gallery/bar-chart-vertical/vertical-bar-chart-stacked.json +306 -19
- package/examples/sparkline.json +868 -0
- package/index.html +128 -123
- package/package.json +4 -2
- package/src/CdcChart.tsx +40 -22
- package/src/_stories/ChartEditor.stories.tsx +14 -3
- package/src/_stories/_mock/url_filter.json +1076 -0
- package/src/components/AreaChart/components/AreaChart.Stacked.jsx +2 -1
- package/src/components/AreaChart/components/AreaChart.jsx +2 -1
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +39 -49
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +36 -56
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +32 -39
- package/src/components/BarChart/components/BarChart.Vertical.tsx +40 -55
- package/src/components/BoxPlot/BoxPlot.jsx +2 -1
- package/src/components/DeviationBar.jsx +3 -3
- package/src/components/EditorPanel/EditorPanel.tsx +167 -15
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +1 -1
- package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +108 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +48 -4
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +41 -0
- package/src/components/EditorPanel/components/Panels/index.tsx +9 -7
- package/src/components/EditorPanel/components/panels.scss +11 -0
- package/src/components/EditorPanel/useEditorPermissions.js +40 -14
- package/src/components/Legend/Legend.Component.tsx +23 -15
- package/src/components/Legend/Legend.tsx +4 -4
- package/src/components/LineChart/LineChartProps.ts +1 -0
- package/src/components/LineChart/helpers.ts +2 -2
- package/src/components/LineChart/index.tsx +7 -7
- package/src/components/LinearChart.jsx +9 -30
- package/src/components/PairedBarChart.jsx +6 -10
- package/src/components/PieChart/PieChart.tsx +3 -3
- package/src/components/Regions/components/Regions.tsx +120 -78
- package/src/components/Sankey/index.tsx +434 -0
- package/src/components/Sankey/sankey.scss +153 -0
- package/src/components/Sankey/types/index.ts +16 -0
- package/src/components/ScatterPlot/ScatterPlot.jsx +1 -0
- package/src/components/Sparkline/{SparkLine.jsx → components/SparkLine.tsx} +14 -30
- package/src/components/Sparkline/index.scss +3 -0
- package/src/components/Sparkline/index.tsx +1 -1
- package/src/components/ZoomBrush.tsx +2 -1
- package/src/data/initial-state.js +46 -2
- package/src/helpers/computeMarginBottom.ts +2 -1
- package/src/helpers/tests/computeMarginBottom.test.ts +2 -1
- package/src/hooks/useBarChart.js +5 -2
- package/src/hooks/useScales.ts +15 -18
- package/src/hooks/useTooltip.tsx +9 -8
- package/src/scss/main.scss +8 -29
- package/src/types/ChartConfig.ts +32 -14
- package/src/types/ChartContext.ts +7 -0
|
@@ -1,102 +1,153 @@
|
|
|
1
|
-
import React, { useContext } from 'react'
|
|
2
|
-
import { ChartConfig } from '../../../types/ChartConfig'
|
|
1
|
+
import React, { MouseEventHandler, useContext } from 'react'
|
|
3
2
|
import ConfigContext from '../../../ConfigContext'
|
|
4
3
|
import { ChartContext } from '../../../types/ChartContext'
|
|
5
4
|
import { Text } from '@visx/text'
|
|
6
5
|
import { Group } from '@visx/group'
|
|
7
|
-
import
|
|
8
|
-
import { formatDate } from '@cdc/core/helpers/cove/date.js'
|
|
6
|
+
import { formatDate, isDateScale } from '@cdc/core/helpers/cove/date.js'
|
|
9
7
|
|
|
10
8
|
type RegionsProps = {
|
|
11
9
|
xScale: Function
|
|
12
10
|
yMax: number
|
|
13
11
|
barWidth?: number
|
|
14
12
|
totalBarsInGroup?: number
|
|
13
|
+
handleTooltipMouseOff: MouseEventHandler<SVGElement>
|
|
14
|
+
handleTooltipMouseOver: MouseEventHandler<SVGElement>
|
|
15
|
+
handleTooltipClick: MouseEventHandler<SVGElement>
|
|
16
|
+
tooltipData: unknown
|
|
17
|
+
showTooltip: Function
|
|
18
|
+
hideTooltip: Function
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
// TODO: should regions be removed on categorical axis?
|
|
22
|
+
const Regions: React.FC<RegionsProps> = ({ xScale, barWidth = 0, totalBarsInGroup = 1, yMax, handleTooltipMouseOff, handleTooltipMouseOver, handleTooltipClick, tooltipData, showTooltip, hideTooltip }) => {
|
|
18
23
|
const { parseDate, config } = useContext<ChartContext>(ConfigContext)
|
|
19
24
|
|
|
20
25
|
const { runtime, regions, visualizationType, orientation, xAxis } = config
|
|
26
|
+
const domain = xScale.domain()
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
let width
|
|
28
|
+
const getFromValue = region => {
|
|
29
|
+
let from
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
// Fixed Date
|
|
32
|
+
if (!region?.fromType || region.fromType === 'Fixed') {
|
|
33
|
+
const date = new Date(region.from)
|
|
34
|
+
const parsedDate = parseDate(formatDate(config.xAxis.dateParseFormat, date)).getTime()
|
|
35
|
+
from = xScale(parsedDate)
|
|
36
|
+
|
|
37
|
+
if (visualizationType === 'Bar' && xAxis.type === 'date-time') {
|
|
38
|
+
from = from - (barWidth * totalBarsInGroup) / 2
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Previous Date
|
|
43
|
+
if (region.fromType === 'Previous Days') {
|
|
44
|
+
const previousDays = Number(region.from) || 0
|
|
45
|
+
const categoricalDomain = domain.map(d => formatDate(config.xAxis.dateParseFormat, new Date(d)))
|
|
46
|
+
const d = region.toType === 'Last Date' ? new Date(domain[domain.length - 1]).getTime() : new Date(region.to) // on categorical charts force leading zero 03/15/2016 vs 3/15/2016 for valid date format
|
|
47
|
+
const to = config.xAxis.type === 'categorical' ? formatDate(config.xAxis.dateParseFormat, d) : formatDate(config.xAxis.dateParseFormat, d)
|
|
48
|
+
const toDate = new Date(to)
|
|
49
|
+
from = new Date(toDate.setDate(toDate.getDate() - Number(previousDays)))
|
|
50
|
+
|
|
51
|
+
if (xAxis.type === 'date') {
|
|
52
|
+
from = new Date(formatDate(xAxis.dateParseFormat, from)).getTime()
|
|
53
|
+
|
|
54
|
+
let closestDate = domain[0]
|
|
55
|
+
let minDiff = Math.abs(from - closestDate)
|
|
56
|
+
|
|
57
|
+
for (let i = 1; i < domain.length; i++) {
|
|
58
|
+
const diff = Math.abs(from - domain[i])
|
|
59
|
+
if (diff < minDiff) {
|
|
60
|
+
minDiff = diff
|
|
61
|
+
closestDate = domain[i]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
from = closestDate
|
|
32
65
|
}
|
|
33
66
|
|
|
67
|
+
// Here the domain is in the xScale.dateParseFormat
|
|
34
68
|
if (xAxis.type === 'categorical') {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
let closestDate = domain[0]
|
|
70
|
+
let minDiff = Math.abs(new Date(from).getTime() - new Date(closestDate).getTime())
|
|
71
|
+
|
|
72
|
+
for (let i = 1; i < domain.length; i++) {
|
|
73
|
+
const diff = Math.abs(new Date(from).getTime() - new Date(domain[i]).getTime())
|
|
74
|
+
if (diff < minDiff) {
|
|
75
|
+
minDiff = diff
|
|
76
|
+
closestDate = domain[i]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
from = closestDate
|
|
38
80
|
}
|
|
39
81
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
to = region.toType !== 'Last Date' ? xScale(parseDate(region.to).getTime()) + (barWidth * totalBarsInGroup) / 2 : null
|
|
82
|
+
from = xScale(from)
|
|
83
|
+
}
|
|
43
84
|
|
|
44
|
-
|
|
45
|
-
|
|
85
|
+
if (xAxis.type === 'categorical' && region.fromType !== 'Previous Days') {
|
|
86
|
+
from = xScale(region.from)
|
|
87
|
+
}
|
|
46
88
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
89
|
+
if (visualizationType === 'Line' || visualizationType === 'Area Chart') {
|
|
90
|
+
let scalePadding = Number(config.yAxis.size)
|
|
91
|
+
if (xScale.bandwidth) {
|
|
92
|
+
scalePadding += xScale.bandwidth() / 2
|
|
51
93
|
}
|
|
94
|
+
from = from + scalePadding
|
|
95
|
+
}
|
|
52
96
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let domain = xScale.domain()
|
|
57
|
-
let bisectDate = d3.bisector(d => d).left
|
|
58
|
-
let closestValue
|
|
59
|
-
|
|
60
|
-
let previousDays = Number(region.from)
|
|
61
|
-
let lastDate = region.toType === 'Last Date' ? domain[domain.length - 1] : region.to
|
|
62
|
-
let toDate = new Date(lastDate)
|
|
63
|
-
|
|
64
|
-
from = new Date(toDate.setDate(toDate.getDate() - previousDays)).getTime()
|
|
65
|
-
let targetValue = from
|
|
66
|
-
|
|
67
|
-
let index = bisectDate(domain, targetValue)
|
|
68
|
-
if (index === 0) {
|
|
69
|
-
closestValue = domain[0]
|
|
70
|
-
} else if (index === domain.length) {
|
|
71
|
-
closestValue = domain[domain.length - 1]
|
|
72
|
-
} else {
|
|
73
|
-
let d0 = domain[index - 1]
|
|
74
|
-
let d1 = domain[index]
|
|
75
|
-
closestValue = targetValue - d0 > d1 - targetValue ? d1 : d0
|
|
76
|
-
}
|
|
77
|
-
from = Number(xScale(closestValue) - (visualizationType === 'Bar' || visualizationType === 'Combo' ? (barWidth * totalBarsInGroup) / 2 : 0))
|
|
97
|
+
if (visualizationType === 'Bar' && config.xAxis.type === 'date-time' && region.fromType === 'Previous Days') {
|
|
98
|
+
from = from - (barWidth * totalBarsInGroup) / 2
|
|
99
|
+
}
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
return from
|
|
102
|
+
}
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
const getToValue = region => {
|
|
105
|
+
let to
|
|
106
|
+
|
|
107
|
+
// when xScale is categorical leading zeros are removed, ie. 03/15/2016 is 3/15/2016
|
|
108
|
+
if (xAxis.type === 'categorical') {
|
|
109
|
+
to = xScale(region.to)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isDateScale(xAxis)) {
|
|
113
|
+
if (!region?.toType || region.toType === 'Fixed') {
|
|
114
|
+
to = xScale(parseDate(region.to).getTime())
|
|
88
115
|
}
|
|
89
116
|
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
let previousDays = Number(region.from)
|
|
93
|
-
let to = region.toType === 'Last Date' ? formatDate(config.xAxis.dateParseFormat, domain[domain.length - 1]) : region.to
|
|
94
|
-
let toDate = new Date(to)
|
|
95
|
-
from = new Date(toDate.setDate(toDate.getDate() - previousDays)).getTime()
|
|
96
|
-
from = xScale(from)
|
|
97
|
-
to = xScale(parseDate(to))
|
|
98
|
-
width = to - from
|
|
117
|
+
if (visualizationType === 'Bar' || config.visualizationType === 'Combo') {
|
|
118
|
+
to = region.toType !== 'Last Date' ? xScale(parseDate(region.to).getTime()) + barWidth * totalBarsInGroup : to
|
|
99
119
|
}
|
|
120
|
+
}
|
|
121
|
+
if (region.toType === 'Last Date') {
|
|
122
|
+
const lastDate = domain[domain.length - 1]
|
|
123
|
+
to = Number(xScale(lastDate) + ((visualizationType === 'Bar' || visualizationType === 'Combo') && config.xAxis.type === 'date' ? barWidth * totalBarsInGroup : 0))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (visualizationType === 'Line' || visualizationType === 'Area Chart') {
|
|
127
|
+
let scalePadding = Number(config.yAxis.size)
|
|
128
|
+
if (xScale.bandwidth) {
|
|
129
|
+
scalePadding += xScale.bandwidth() / 2
|
|
130
|
+
}
|
|
131
|
+
to = to + scalePadding
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (visualizationType === 'Bar' && config.xAxis.type === 'date-time' && region.toType !== 'Last Date') {
|
|
135
|
+
to = to - (barWidth * totalBarsInGroup) / 2
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if ((visualizationType === 'Bar' || visualizationType === 'Combo') && xAxis.type === 'categorical') {
|
|
139
|
+
to = to + (visualizationType === 'Bar' || visualizationType === 'Combo' ? barWidth * totalBarsInGroup : 0)
|
|
140
|
+
}
|
|
141
|
+
return to
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const getWidth = (to, from) => to - from
|
|
145
|
+
|
|
146
|
+
if (regions && orientation === 'vertical') {
|
|
147
|
+
return regions.map(region => {
|
|
148
|
+
const from = getFromValue(region)
|
|
149
|
+
const to = getToValue(region)
|
|
150
|
+
const width = getWidth(to, from)
|
|
100
151
|
|
|
101
152
|
if (!from) return null
|
|
102
153
|
if (!to) return null
|
|
@@ -120,16 +171,7 @@ const Regions = ({ xScale, barWidth = 0, totalBarsInGroup = 1, yMax, handleToolt
|
|
|
120
171
|
}
|
|
121
172
|
|
|
122
173
|
return (
|
|
123
|
-
<Group
|
|
124
|
-
className='regions regions-group--line'
|
|
125
|
-
left={config.visualizationType === 'Bar' || config.visualizationType === 'Combo' ? 0 : config?.visualizationType === 'Line' ? Number(runtime.yAxis.size) : 0}
|
|
126
|
-
key={region.label}
|
|
127
|
-
onMouseMove={handleTooltipMouseOver}
|
|
128
|
-
onMouseLeave={handleTooltipMouseOff}
|
|
129
|
-
handleTooltipClick={handleTooltipClick}
|
|
130
|
-
tooltipData={JSON.stringify(tooltipData)}
|
|
131
|
-
showTooltip={showTooltip}
|
|
132
|
-
>
|
|
174
|
+
<Group height={100} fill='red' className='regions regions-group--line zzz' key={region.label} onMouseMove={handleTooltipMouseOver} onMouseLeave={handleTooltipMouseOff} handleTooltipClick={handleTooltipClick} tooltipData={JSON.stringify(tooltipData)} showTooltip={showTooltip}>
|
|
133
175
|
<TopRegionBorderShape />
|
|
134
176
|
<HighlightedArea />
|
|
135
177
|
<Text x={from + width / 2} y={5} fill={region.color} verticalAnchor='start' textAnchor='middle'>
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { useContext, useState, useRef, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
// External Libraries
|
|
4
|
+
import { PlacesType, Tooltip as ReactTooltip } from 'react-tooltip'
|
|
5
|
+
import { SankeyGraph, sankey, sankeyLinkHorizontal, sankeyLeft } from 'd3-sankey'
|
|
6
|
+
import { Group } from '@visx/group'
|
|
7
|
+
import { Text } from '@visx/text'
|
|
8
|
+
import ReactDOMServer from 'react-dom/server'
|
|
9
|
+
|
|
10
|
+
// Cdc
|
|
11
|
+
import './sankey.scss'
|
|
12
|
+
import 'react-tooltip/dist/react-tooltip.css'
|
|
13
|
+
import ConfigContext from '@cdc/chart/src/ConfigContext'
|
|
14
|
+
import { ChartContext } from '../../types/ChartContext'
|
|
15
|
+
import type { SankeyNode, SankeyProps } from './types'
|
|
16
|
+
|
|
17
|
+
const Sankey = ({ width, height, runtime }: SankeyProps) => {
|
|
18
|
+
const DEBUG = true
|
|
19
|
+
const { config } = useContext<ChartContext>(ConfigContext)
|
|
20
|
+
const { sankey: sankeyConfig } = config
|
|
21
|
+
// !info - changed config.sankey.data here to work with our current upload pattern saved on config.data
|
|
22
|
+
const data = config?.data[0]
|
|
23
|
+
const [largestGroupWidth, setLargestGroupWidth] = useState(0)
|
|
24
|
+
const groupRefs = useRef([])
|
|
25
|
+
|
|
26
|
+
//Tooltip
|
|
27
|
+
const [tooltipID, setTooltipID] = useState<string>('')
|
|
28
|
+
|
|
29
|
+
const handleNodeClick = (nodeId: string) => {
|
|
30
|
+
setTooltipID(nodeId)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const clearNodeClick = () => {
|
|
34
|
+
setTooltipID('')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//Mobile Pop Up
|
|
38
|
+
const [showPopup, setShowPopup] = useState(false)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (window.innerWidth < 768 && window.innerHeight > window.innerWidth) {
|
|
42
|
+
setShowPopup(true)
|
|
43
|
+
}
|
|
44
|
+
}, [window.innerWidth])
|
|
45
|
+
|
|
46
|
+
const closePopUp = () => {
|
|
47
|
+
setShowPopup(false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Uses Visx Groups innerRef to get all Group elements that are mapped.
|
|
51
|
+
// Sets the largest group width in state and subtracts that group the svg width to calculate overall width.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let largest = 0
|
|
54
|
+
groupRefs?.current?.map(g => {
|
|
55
|
+
const groupWidth = g?.getBoundingClientRect().width
|
|
56
|
+
if (groupWidth > largest) {
|
|
57
|
+
largest = groupWidth
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
setLargestGroupWidth(largest)
|
|
61
|
+
}, [groupRefs, sankeyConfig, window.innerWidth])
|
|
62
|
+
|
|
63
|
+
//Retrieve all the unique values for the Nodes
|
|
64
|
+
const uniqueNodes = Array.from(new Set(data?.links?.flatMap(link => [link.source, link.target])))
|
|
65
|
+
|
|
66
|
+
// Convert JSON data to the format required
|
|
67
|
+
const sankeyData: SankeyGraph<SankeyNode, { source: number; target: number }> = {
|
|
68
|
+
nodes: uniqueNodes.map(nodeId => ({ id: nodeId })),
|
|
69
|
+
links: data?.links?.map(link => ({
|
|
70
|
+
source: uniqueNodes.findIndex(node => node === link.source),
|
|
71
|
+
target: uniqueNodes.findIndex(node => node === link.target),
|
|
72
|
+
value: link.value
|
|
73
|
+
}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let textPositionHorizontal = 5
|
|
77
|
+
const BUFFER = 50
|
|
78
|
+
|
|
79
|
+
// Set the sankey diagram properties console.log('largestGroupWidth', largestGroupWidth)
|
|
80
|
+
|
|
81
|
+
const sankeyGenerator = sankey<SankeyNode, { source: number; target: number }>()
|
|
82
|
+
.nodeWidth(sankeyConfig.nodeSize.nodeWidth)
|
|
83
|
+
.nodePadding(sankeyConfig.nodePadding)
|
|
84
|
+
.iterations(sankeyConfig.iterations)
|
|
85
|
+
.nodeAlign(sankeyLeft)
|
|
86
|
+
.extent([
|
|
87
|
+
[sankeyConfig.margin.margin_x, Number(sankeyConfig.margin.margin_y)],
|
|
88
|
+
[width - textPositionHorizontal - largestGroupWidth, config.heights.vertical - BUFFER]
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
const { nodes, links } = sankeyGenerator(sankeyData)
|
|
92
|
+
|
|
93
|
+
const nodeStyle = (id: string) => {
|
|
94
|
+
let textPositionHorizontal = 30
|
|
95
|
+
let textPositionVertical = 0
|
|
96
|
+
let classStyle = 'node-value--storynode'
|
|
97
|
+
let storyNodes = true
|
|
98
|
+
|
|
99
|
+
// TODO: need a dynamic way to apply classes here instead of checking static values.
|
|
100
|
+
|
|
101
|
+
if (data?.storyNodeText?.every(node => node.StoryNode !== id)) {
|
|
102
|
+
storyNodes = false
|
|
103
|
+
textPositionVertical = 10
|
|
104
|
+
textPositionHorizontal = 8
|
|
105
|
+
classStyle = 'node-value'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { textPositionHorizontal, textPositionVertical, classStyle, storyNodes }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const activeConnection = (id: String) => {
|
|
112
|
+
const currentNode = sankeyData.nodes.find(node => node.id === id)
|
|
113
|
+
|
|
114
|
+
const sourceNodes = []
|
|
115
|
+
const activeLinks = []
|
|
116
|
+
|
|
117
|
+
if (currentNode) {
|
|
118
|
+
links.forEach(link => {
|
|
119
|
+
const targetObj: any = link.target
|
|
120
|
+
const sourceObj: any = link.source
|
|
121
|
+
if (targetObj.id === id) {
|
|
122
|
+
sourceNodes.push(sourceObj.id)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
sourceNodes.forEach(id => {
|
|
127
|
+
links.forEach(link => {
|
|
128
|
+
const targetObj: any = link.target
|
|
129
|
+
const sourceObj: any = link.source
|
|
130
|
+
if (targetObj.id === tooltipID && sourceObj.id === id) {
|
|
131
|
+
activeLinks.push(link)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { sourceNodes, activeLinks }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tooltipVal = `${(data?.tooltips.find(item => item.node === tooltipID) || {}).value}`
|
|
141
|
+
const tooltipSummary = `${(data?.tooltips.find(item => item.node === tooltipID) || {}).summary}`
|
|
142
|
+
const tooltipColumn1Label = (data?.tooltips.find(item => item.node === tooltipID) || {}).column1Label
|
|
143
|
+
const tooltipColumn2Label = (data?.tooltips.find(item => item.node === tooltipID) || {}).column2Label
|
|
144
|
+
const tooltipColumn1 = (data?.tooltips.find(item => item.node === tooltipID) || {}).column1
|
|
145
|
+
const tooltipColumn2 = (data?.tooltips.find(item => item.node === tooltipID) || {}).column2
|
|
146
|
+
|
|
147
|
+
const ColumnList = ({ columnData }) => {
|
|
148
|
+
return (
|
|
149
|
+
<ul>
|
|
150
|
+
{columnData?.map((entry, index) => (
|
|
151
|
+
<li key={index}>
|
|
152
|
+
{entry.label}: {entry.value} ({entry.additional_info}%)
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tooltipColumn1Data = ReactDOMServer.renderToString(<ColumnList columnData={tooltipColumn1} />)
|
|
160
|
+
const tooltipColumn2Data = ReactDOMServer.renderToString(<ColumnList columnData={tooltipColumn2} />)
|
|
161
|
+
|
|
162
|
+
const sankeyToolTip = `<div class="sankey-chart__tooltip">
|
|
163
|
+
<span class="sankey-chart__tooltip--tooltip-header">${tooltipID}</span>
|
|
164
|
+
<span class="sankey-chart__tooltip--tooltip-header">${tooltipVal}</span>
|
|
165
|
+
<div class="divider"></div>
|
|
166
|
+
<span><strong>Summary: </strong>${tooltipSummary}</span>
|
|
167
|
+
<div class="divider"></div>
|
|
168
|
+
<div class="sankey-chart__tooltip--info-section">
|
|
169
|
+
<div>
|
|
170
|
+
<span><strong>${tooltipColumn1Label}<strong></span>
|
|
171
|
+
${tooltipColumn1Data}
|
|
172
|
+
</div>
|
|
173
|
+
<div>
|
|
174
|
+
<span><strong>${tooltipColumn2Label}<strong></span>
|
|
175
|
+
${tooltipColumn2Data}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>`
|
|
179
|
+
|
|
180
|
+
// Draw the nodes
|
|
181
|
+
const allNodes = sankeyData.nodes.map((node, i) => {
|
|
182
|
+
let { textPositionHorizontal, textPositionVertical, classStyle, storyNodes } = nodeStyle(node.id)
|
|
183
|
+
let { sourceNodes } = activeConnection(tooltipID)
|
|
184
|
+
|
|
185
|
+
let opacityValue = sankeyConfig.opacity.nodeOpacityDefault
|
|
186
|
+
let nodeColor = sankeyConfig.nodeColor.default
|
|
187
|
+
|
|
188
|
+
if (tooltipID !== node.id && tooltipID !== '' && !sourceNodes.includes(node.id)) {
|
|
189
|
+
nodeColor = sankeyConfig.nodeColor.inactive
|
|
190
|
+
opacityValue = sankeyConfig.opacity.nodeOpacityInactive
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<Group className='' key={i}>
|
|
195
|
+
<rect
|
|
196
|
+
height={node.y1! - node.y0! + 2} // increasing node size to account for smaller nodes
|
|
197
|
+
width={sankeyGenerator.nodeWidth()}
|
|
198
|
+
x={node.x0}
|
|
199
|
+
y={node.y0! - 1} //adjusting here the node starts so it looks more center with the link
|
|
200
|
+
fill={nodeColor}
|
|
201
|
+
fillOpacity={opacityValue}
|
|
202
|
+
rx={sankeyConfig.rxValue}
|
|
203
|
+
// todo: move enable tooltips to sankey
|
|
204
|
+
data-tooltip-html={data.tooltips && config.enableTooltips ? sankeyToolTip : null}
|
|
205
|
+
data-tooltip-id={`cdc-open-viz-tooltip-${runtime.uniqueId}-sankey`}
|
|
206
|
+
onClick={() => handleNodeClick(node.id)}
|
|
207
|
+
style={{ pointerEvents: 'visible', cursor: 'pointer' }}
|
|
208
|
+
/>
|
|
209
|
+
{storyNodes ? (
|
|
210
|
+
<>
|
|
211
|
+
<Text
|
|
212
|
+
/* Text Position Horizontal
|
|
213
|
+
x0 is the left edge of the node
|
|
214
|
+
# - positions text # units to the right of the left edge of the node */
|
|
215
|
+
x={node.x0! + textPositionHorizontal}
|
|
216
|
+
textAnchor={sankeyData.nodes.length - 1 === i ? 'end' : 'start'}
|
|
217
|
+
verticalAnchor='end'
|
|
218
|
+
/*Text Position Vertical
|
|
219
|
+
y1 and y0 are the top and bottom edges of the node
|
|
220
|
+
y1+y0 = total height
|
|
221
|
+
dividing by 2 gives you the midpoint of the node
|
|
222
|
+
minus 30 raises the vertical position to be higher
|
|
223
|
+
*/
|
|
224
|
+
y={(node.y1! + node.y0!) / 2 - 30}
|
|
225
|
+
/* Using x and y in combination with dominant baseline allows for a more
|
|
226
|
+
precise positioning of the text within the svg
|
|
227
|
+
dominant baseline allows for different vertical alignments
|
|
228
|
+
text-before-edge aligns the text's bottom edge with the bottom edge of the container
|
|
229
|
+
*/
|
|
230
|
+
fill={sankeyConfig.nodeFontColor}
|
|
231
|
+
fontWeight='bold' // font weight
|
|
232
|
+
style={{ pointerEvents: 'none' }}
|
|
233
|
+
className='node-text'
|
|
234
|
+
>
|
|
235
|
+
{(data?.storyNodeText?.find(storyNode => storyNode.StoryNode === node.id) || {}).segmentTextBefore}
|
|
236
|
+
</Text>
|
|
237
|
+
<Text verticalAnchor='end' className={classStyle} x={node.x0! + textPositionHorizontal} y={(node.y1! + node.y0! + 25) / 2} fill={sankeyConfig.storyNodeFontColor || sankeyConfig.nodeFontColor} fontWeight='bold' textAnchor='start' style={{ pointerEvents: 'none' }}>
|
|
238
|
+
{typeof node.value === 'number' ? node.value.toLocaleString() : node.value}
|
|
239
|
+
</Text>
|
|
240
|
+
<Text
|
|
241
|
+
x={node.x0! + textPositionHorizontal}
|
|
242
|
+
// plus 50 will move the vertical position down
|
|
243
|
+
y={(node.y1! + node.y0!) / 2 + 50}
|
|
244
|
+
fill={sankeyConfig.nodeFontColor}
|
|
245
|
+
fontWeight='bold'
|
|
246
|
+
textAnchor={sankeyData.nodes.length === i ? 'end' : 'start'}
|
|
247
|
+
style={{ pointerEvents: 'none' }}
|
|
248
|
+
className='node-text'
|
|
249
|
+
verticalAnchor='end'
|
|
250
|
+
>
|
|
251
|
+
{(data?.storyNodeText?.find(storyNode => storyNode.StoryNode === node.id) || {}).segmentTextAfter}
|
|
252
|
+
</Text>
|
|
253
|
+
</>
|
|
254
|
+
) : (
|
|
255
|
+
<>
|
|
256
|
+
<text x={node.x0! + textPositionHorizontal} y={(node.y1! + node.y0!) / 2 + textPositionVertical} dominantBaseline='text-before-edge' fill={sankeyConfig.nodeFontColor} fontWeight='bold' textAnchor='start' style={{ pointerEvents: 'none' }}>
|
|
257
|
+
<tspan id={node.id} className='node-id'>
|
|
258
|
+
{node.id}
|
|
259
|
+
</tspan>
|
|
260
|
+
</text>
|
|
261
|
+
<text
|
|
262
|
+
x={node.x0! + textPositionHorizontal}
|
|
263
|
+
/* adding 30 allows the node value to be on the next line underneath the node id */
|
|
264
|
+
y={(node.y1! + node.y0!) / 2 + 30}
|
|
265
|
+
dominantBaseline='text-before-edge'
|
|
266
|
+
fill={sankeyConfig.nodeFontColor}
|
|
267
|
+
//fontSize={16}
|
|
268
|
+
fontWeight='bold'
|
|
269
|
+
textAnchor='start'
|
|
270
|
+
style={{ pointerEvents: 'none' }}
|
|
271
|
+
>
|
|
272
|
+
<tspan className={classStyle}>{sankeyConfig.nodeValueStyle.textBefore + (typeof node.value === 'number' ? node.value.toLocaleString() : node.value) + sankeyConfig.nodeValueStyle.textAfter}</tspan>
|
|
273
|
+
</text>
|
|
274
|
+
</>
|
|
275
|
+
)}
|
|
276
|
+
</Group>
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Draw the links
|
|
281
|
+
const allLinks = links.map((link, i) => {
|
|
282
|
+
const linkGenerator = sankeyLinkHorizontal()
|
|
283
|
+
const path = linkGenerator(link)
|
|
284
|
+
let opacityValue = sankeyConfig.opacity.LinkOpacityDefault
|
|
285
|
+
let strokeColor = sankeyConfig.linkColor.default
|
|
286
|
+
|
|
287
|
+
let { activeLinks } = activeConnection(tooltipID)
|
|
288
|
+
|
|
289
|
+
if (!activeLinks.includes(link) && tooltipID !== '') {
|
|
290
|
+
strokeColor = sankeyConfig.linkColor.inactive
|
|
291
|
+
opacityValue = sankeyConfig.opacity.LinkOpacityInactive
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return <path key={i} d={path!} stroke={strokeColor} fill='none' strokeOpacity={opacityValue} strokeWidth={link.width! + 2} />
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// max depth - calculates how many nodes deep the chart goes.
|
|
298
|
+
const maxDepth: number = sankeyData.nodes.reduce((maxDepth, node) => {
|
|
299
|
+
return Math.max(maxDepth, node.depth)
|
|
300
|
+
}, -1)
|
|
301
|
+
|
|
302
|
+
// finalNodesAtMaxDepth - get only the right most nodes on the chart.
|
|
303
|
+
const finalNodesAtMaxDepth = sankeyData.nodes.filter(node => node.depth === maxDepth)
|
|
304
|
+
|
|
305
|
+
const finalNodes = finalNodesAtMaxDepth.map((node, i) => {
|
|
306
|
+
let { textPositionHorizontal, textPositionVertical, classStyle, storyNodes } = nodeStyle(node.id)
|
|
307
|
+
let { sourceNodes } = activeConnection(tooltipID)
|
|
308
|
+
|
|
309
|
+
let opacityValue = sankeyConfig.opacity.nodeOpacityDefault
|
|
310
|
+
let nodeColor = sankeyConfig.nodeColor.default
|
|
311
|
+
|
|
312
|
+
if (tooltipID !== node.id && tooltipID !== '' && !sourceNodes.includes(node.id)) {
|
|
313
|
+
nodeColor = sankeyConfig.nodeColor.inactive
|
|
314
|
+
opacityValue = sankeyConfig.opacity.nodeOpacityInactive
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Group className='' key={i} innerRef={el => (groupRefs.current[i] = el)}>
|
|
319
|
+
<rect
|
|
320
|
+
height={node.y1! - node.y0! + 2} // increasing node size to account for smaller nodes
|
|
321
|
+
width={sankeyGenerator.nodeWidth()}
|
|
322
|
+
x={node.x0}
|
|
323
|
+
y={node.y0! - 1} //adjusting here the node starts so it looks more center with the link
|
|
324
|
+
fill={nodeColor}
|
|
325
|
+
fillOpacity={opacityValue}
|
|
326
|
+
rx={sankeyConfig.rxValue}
|
|
327
|
+
// todo: move enable tooltips to sankey
|
|
328
|
+
data-tooltip-html={data.tooltips && config.enableTooltips ? sankeyToolTip : null}
|
|
329
|
+
data-tooltip-id={`tooltip`}
|
|
330
|
+
onClick={() => handleNodeClick(node.id)}
|
|
331
|
+
style={{ pointerEvents: 'visible', cursor: 'pointer' }}
|
|
332
|
+
/>
|
|
333
|
+
{storyNodes ? (
|
|
334
|
+
<>
|
|
335
|
+
<Text
|
|
336
|
+
/* Text Position Horizontal
|
|
337
|
+
x0 is the left edge of the node
|
|
338
|
+
# - positions text # units to the right of the left edge of the node */
|
|
339
|
+
x={node.x0! + textPositionHorizontal}
|
|
340
|
+
textAnchor={sankeyData.nodes.length - 1 === i ? 'end' : 'start'}
|
|
341
|
+
verticalAnchor='end'
|
|
342
|
+
/*Text Position Vertical
|
|
343
|
+
y1 and y0 are the top and bottom edges of the node
|
|
344
|
+
y1+y0 = total height
|
|
345
|
+
dividing by 2 gives you the midpoint of the node
|
|
346
|
+
minus 30 raises the vertical position to be higher
|
|
347
|
+
*/
|
|
348
|
+
y={(node.y1! + node.y0!) / 2 - 30}
|
|
349
|
+
/* Using x and y in combination with dominant baseline allows for a more
|
|
350
|
+
precise positioning of the text within the svg
|
|
351
|
+
dominant baseline allows for different vertical alignments
|
|
352
|
+
text-before-edge aligns the text's bottom edge with the bottom edge of the container
|
|
353
|
+
*/
|
|
354
|
+
fill={sankeyConfig.nodeFontColor}
|
|
355
|
+
fontWeight='bold' // font weight
|
|
356
|
+
style={{ pointerEvents: 'none' }}
|
|
357
|
+
className='node-text'
|
|
358
|
+
>
|
|
359
|
+
{(data?.storyNodeText?.find(storyNode => storyNode.StoryNode === node.id) || {}).segmentTextBefore}
|
|
360
|
+
</Text>
|
|
361
|
+
<Text verticalAnchor='end' className={classStyle} x={node.x0! + textPositionHorizontal} y={(node.y1! + node.y0! + 25) / 2} fill={sankeyConfig.storyNodeFontColor || sankeyConfig.nodeFontColor} fontWeight='bold' textAnchor='start' style={{ pointerEvents: 'none' }}>
|
|
362
|
+
{typeof node.value === 'number' ? node.value.toLocaleString() : node.value}
|
|
363
|
+
</Text>
|
|
364
|
+
<Text
|
|
365
|
+
x={node.x0! + textPositionHorizontal}
|
|
366
|
+
// plus 50 will move the vertical position down
|
|
367
|
+
y={(node.y1! + node.y0!) / 2 + 50}
|
|
368
|
+
fill={sankeyConfig.nodeFontColor}
|
|
369
|
+
fontWeight='bold'
|
|
370
|
+
textAnchor={sankeyData.nodes.length === i ? 'end' : 'start'}
|
|
371
|
+
style={{ pointerEvents: 'none' }}
|
|
372
|
+
className='node-text'
|
|
373
|
+
verticalAnchor='end'
|
|
374
|
+
>
|
|
375
|
+
{(data?.storyNodeText?.find(storyNode => storyNode.StoryNode === node.id) || {}).segmentTextAfter}
|
|
376
|
+
</Text>
|
|
377
|
+
</>
|
|
378
|
+
) : (
|
|
379
|
+
<>
|
|
380
|
+
<text x={node.x0! + textPositionHorizontal} y={(node.y1! + node.y0!) / 2 + textPositionVertical} dominantBaseline='text-before-edge' fill={sankeyConfig.nodeFontColor} fontWeight='bold' textAnchor='start' style={{ pointerEvents: 'none' }}>
|
|
381
|
+
<tspan id={node.id} className='node-id'>
|
|
382
|
+
{node.id}
|
|
383
|
+
</tspan>
|
|
384
|
+
</text>
|
|
385
|
+
<text
|
|
386
|
+
x={node.x0! + textPositionHorizontal}
|
|
387
|
+
/* adding 30 allows the node value to be on the next line underneath the node id */
|
|
388
|
+
y={(node.y1! + node.y0!) / 2 + 30}
|
|
389
|
+
dominantBaseline='text-before-edge'
|
|
390
|
+
fill={sankeyConfig.nodeFontColor}
|
|
391
|
+
//fontSize={16}
|
|
392
|
+
fontWeight='bold'
|
|
393
|
+
textAnchor='start'
|
|
394
|
+
style={{ pointerEvents: 'none' }}
|
|
395
|
+
>
|
|
396
|
+
<tspan className={classStyle}>{sankeyConfig.nodeValueStyle.textBefore + (typeof node.value === 'number' ? node.value.toLocaleString() : node.value) + sankeyConfig.nodeValueStyle.textAfter}</tspan>
|
|
397
|
+
</text>
|
|
398
|
+
</>
|
|
399
|
+
)}
|
|
400
|
+
</Group>
|
|
401
|
+
)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<>
|
|
406
|
+
<div className='sankey-chart'>
|
|
407
|
+
<svg className='sankey-chart__diagram' width={width} height={Number(config.heights.vertical)} style={{ overflow: 'visible' }}>
|
|
408
|
+
<Group className='links'>{allLinks}</Group>
|
|
409
|
+
<Group className='nodes'>{allNodes}</Group>
|
|
410
|
+
<Group className='finalNodes' style={{ display: 'none' }}>
|
|
411
|
+
{finalNodes}
|
|
412
|
+
</Group>
|
|
413
|
+
</svg>
|
|
414
|
+
|
|
415
|
+
{/* ReactTooltip needs to remain even if tooltips are disabled -- it handles when a user clicks off of the node and resets
|
|
416
|
+
the sankey diagram. When tooltips are disabled this will nothing */}
|
|
417
|
+
<ReactTooltip id={`cdc-open-viz-tooltip-${runtime.uniqueId}-sankey`} afterHide={() => clearNodeClick()} events={['click']} place={'bottom'} style={{ backgroundColor: `rgba(238, 238, 238, 1)`, color: 'black', boxShadow: `0 3px 10px rgb(0 0 0 / 0.2)` }} />
|
|
418
|
+
{showPopup && (
|
|
419
|
+
<div className='popup'>
|
|
420
|
+
<div className='popup-content'>
|
|
421
|
+
<button className='visually-hidden' onClick={closePopUp}>
|
|
422
|
+
Select for accessible version.
|
|
423
|
+
</button>
|
|
424
|
+
<p>
|
|
425
|
+
<strong>Please change the orientation of your screen or increase the size of your browser to view the diagram better.</strong>
|
|
426
|
+
</p>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
</>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
export default Sankey
|