1
- import { useLayoutEffect , useRef , useState } from 'react' ;
1
+ import { useCallback , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
2
2
import type { Theme } from '@emotion/react' ;
3
3
import { useTheme } from '@emotion/react' ;
4
4
import styled from '@emotion/styled' ;
5
+ import type { TooltipComponentFormatterCallbackParams } from 'echarts' ;
6
+ import type { CallbackDataParams } from 'echarts/types/dist/shared' ;
5
7
6
8
import BaseChart from 'sentry/components/charts/baseChart' ;
7
9
import { space } from 'sentry/styles/space' ;
@@ -29,6 +31,75 @@ export function Charts({rankedAttributes}: Props) {
29
31
) ;
30
32
}
31
33
34
+ type CohortData = SuspectAttributesResult [ 'rankedAttributes' ] [ number ] [ 'cohort1' ] ;
35
+
36
+ function cohortsToSeriesData (
37
+ cohort1 : CohortData ,
38
+ cohort2 : CohortData
39
+ ) : {
40
+ [ BASELINE_SERIES_NAME ] : Array < { label : string ; value : string } > ;
41
+ [ SELECTED_SERIES_NAME ] : Array < { label : string ; value : string } > ;
42
+ } {
43
+ const cohort1Map = new Map ( cohort1 . map ( ( { label, value} ) => [ label , value ] ) ) ;
44
+ const cohort2Map = new Map ( cohort2 . map ( ( { label, value} ) => [ label , value ] ) ) ;
45
+
46
+ const uniqueLabels = new Set ( [
47
+ ...cohort1 . map ( c => c . label ) ,
48
+ ...cohort2 . map ( c => c . label ) ,
49
+ ] ) ;
50
+
51
+ // From the unique labels, we create two series data objects, one for the selected cohort and one for the baseline cohort.
52
+ // If a label isn't present in either of the cohorts, we assign a value of 0, to that label in the respective series.
53
+ const seriesData = Array . from ( uniqueLabels ) . map ( label => {
54
+ const selectedVal = cohort1Map . get ( label ) ?? '0' ;
55
+ const baselineVal = cohort2Map . get ( label ) ?? '0' ;
56
+
57
+ // We sort by descending value of the selected cohort
58
+ const sortVal = Number ( selectedVal ) ;
59
+
60
+ return {
61
+ label,
62
+ selectedValue : selectedVal ,
63
+ baselineValue : baselineVal ,
64
+ sortValue : sortVal ,
65
+ } ;
66
+ } ) ;
67
+
68
+ seriesData . sort ( ( a , b ) => b . sortValue - a . sortValue ) ;
69
+
70
+ const selectedSeriesData = seriesData . map ( ( { label, selectedValue} ) => ( {
71
+ label,
72
+ value : selectedValue ,
73
+ } ) ) ;
74
+
75
+ const baselineSeriesData = seriesData . map ( ( { label, baselineValue} ) => ( {
76
+ label,
77
+ value : baselineValue ,
78
+ } ) ) ;
79
+
80
+ return {
81
+ [ SELECTED_SERIES_NAME ] : selectedSeriesData ,
82
+ [ BASELINE_SERIES_NAME ] : baselineSeriesData ,
83
+ } ;
84
+ }
85
+
86
+ // TODO Abdullah Khan: This is a temporary function to get the totals of the cohorts. Will be removed
87
+ // once the backend returns the totals.
88
+ function cohortTotals (
89
+ cohort1 : CohortData ,
90
+ cohort2 : CohortData
91
+ ) : {
92
+ [ BASELINE_SERIES_NAME ] : number ;
93
+ [ SELECTED_SERIES_NAME ] : number ;
94
+ } {
95
+ const cohort1Total = cohort1 . reduce ( ( acc , curr ) => acc + Number ( curr . value ) , 0 ) ;
96
+ const cohort2Total = cohort2 . reduce ( ( acc , curr ) => acc + Number ( curr . value ) , 0 ) ;
97
+ return {
98
+ [ SELECTED_SERIES_NAME ] : cohort1Total ,
99
+ [ BASELINE_SERIES_NAME ] : cohort2Total ,
100
+ } ;
101
+ }
102
+
32
103
function Chart ( {
33
104
attribute,
34
105
theme,
@@ -42,6 +113,68 @@ function Chart({
42
113
const cohort1Color = theme . chart . getColorPalette ( 0 ) ?. [ 0 ] ;
43
114
const cohort2Color = '#dddddd' ;
44
115
116
+ const seriesData = useMemo (
117
+ ( ) => cohortsToSeriesData ( attribute . cohort1 , attribute . cohort2 ) ,
118
+ [ attribute . cohort1 , attribute . cohort2 ]
119
+ ) ;
120
+
121
+ const seriesTotals = useMemo (
122
+ ( ) => cohortTotals ( attribute . cohort1 , attribute . cohort2 ) ,
123
+ [ attribute . cohort1 , attribute . cohort2 ]
124
+ ) ;
125
+
126
+ const valueFormatter = useCallback (
127
+ ( _value : number , label ?: string , seriesParams ?: CallbackDataParams ) => {
128
+ const data = Number ( seriesParams ?. data ) ;
129
+ const total = seriesTotals [ label as keyof typeof seriesTotals ] ;
130
+ const percentage = ( data / total ) * 100 ;
131
+ return `${ percentage . toFixed ( 1 ) } %` ;
132
+ } ,
133
+ [ seriesTotals ]
134
+ ) ;
135
+
136
+ const formatAxisLabel = useCallback (
137
+ (
138
+ _value : number ,
139
+ _isTimestamp : boolean ,
140
+ _utc : boolean ,
141
+ _showTimeInTooltip : boolean ,
142
+ _addSecondsToTimeFormat : boolean ,
143
+ _bucketSize : number | undefined ,
144
+ seriesParamsOrParam : TooltipComponentFormatterCallbackParams
145
+ ) => {
146
+ if ( ! Array . isArray ( seriesParamsOrParam ) ) {
147
+ throw new Error ( 'seriesParamsOrParam is not an array in formatAxisLabel' ) ;
148
+ }
149
+
150
+ const selectedParam = seriesParamsOrParam [ 0 ] ;
151
+ const baselineParam = seriesParamsOrParam [ 1 ] ;
152
+
153
+ if ( ! selectedParam || ! baselineParam ) {
154
+ throw new Error ( 'selectedParam or baselineParam is not defined' ) ;
155
+ }
156
+
157
+ const selectedTotal =
158
+ seriesTotals [ selectedParam ?. seriesName as keyof typeof seriesTotals ] ;
159
+ const selectedData = Number ( selectedParam ?. data ) ;
160
+ const selectedPercentage = ( selectedData / selectedTotal ) * 100 ;
161
+
162
+ const baselineTotal =
163
+ seriesTotals [ baselineParam ?. seriesName as keyof typeof seriesTotals ] ;
164
+ const baselineData = Number ( baselineParam ?. data ) ;
165
+ const baselinePercentage = ( baselineData / baselineTotal ) * 100 ;
166
+
167
+ const isDifferent = selectedPercentage . toFixed ( 1 ) !== baselinePercentage . toFixed ( 1 ) ;
168
+
169
+ const status = isDifferent
170
+ ? { adjective : 'different' , message : 'This is suspicious.' }
171
+ : { adjective : 'similar' , message : 'Nothing unusual here.' } ;
172
+
173
+ return `<div style="max-width: 200px; white-space: normal; word-wrap: break-word; line-height: 1.2;">${ selectedParam ?. name } <span style="color: ${ theme . textColor } ;">is <strong>${ status . adjective } </strong> ${ isDifferent ? 'between' : 'across' } selected and baseline data. ${ status . message } </span></div>` ;
174
+ } ,
175
+ [ seriesTotals , theme . textColor ]
176
+ ) ;
177
+
45
178
useLayoutEffect ( ( ) => {
46
179
const chartContainer = chartRef . current ?. getEchartsInstance ( ) . getDom ( ) ;
47
180
if ( ! chartContainer ) return ;
@@ -69,8 +202,9 @@ function Chart({
69
202
autoHeightResize
70
203
isGroupedByDate = { false }
71
204
tooltip = { {
72
- trigger : 'axis' ,
73
- confine : true ,
205
+ renderMode : 'html' ,
206
+ valueFormatter,
207
+ formatAxisLabel,
74
208
} }
75
209
grid = { {
76
210
left : 2 ,
@@ -80,7 +214,7 @@ function Chart({
80
214
xAxis = { {
81
215
show : true ,
82
216
type : 'category' ,
83
- data : attribute . cohort1 . map ( cohort => cohort . label ) ,
217
+ data : seriesData [ SELECTED_SERIES_NAME ] . map ( cohort => cohort . label ) ,
84
218
truncate : 14 ,
85
219
axisLabel : hideLabels
86
220
? { show : false }
@@ -103,7 +237,7 @@ function Chart({
103
237
series = { [
104
238
{
105
239
type : 'bar' ,
106
- data : attribute . cohort1 . map ( cohort => cohort . value ) ,
240
+ data : seriesData [ SELECTED_SERIES_NAME ] . map ( cohort => cohort . value ) ,
107
241
name : SELECTED_SERIES_NAME ,
108
242
itemStyle : {
109
243
color : cohort1Color ,
@@ -113,7 +247,7 @@ function Chart({
113
247
} ,
114
248
{
115
249
type : 'bar' ,
116
- data : attribute . cohort2 . map ( cohort => cohort . value ) ,
250
+ data : seriesData [ BASELINE_SERIES_NAME ] . map ( cohort => cohort . value ) ,
117
251
name : BASELINE_SERIES_NAME ,
118
252
itemStyle : {
119
253
color : cohort2Color ,
0 commit comments