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 { Virtualizer } from '@tanstack/react-virtual' ;
6
+ import { useVirtualizer } from '@tanstack/react-virtual' ;
7
+ import type { TooltipComponentFormatterCallbackParams } from 'echarts' ;
8
+ import type { CallbackDataParams } from 'echarts/types/dist/shared' ;
5
9
6
10
import BaseChart from 'sentry/components/charts/baseChart' ;
7
11
import { space } from 'sentry/styles/space' ;
@@ -15,33 +19,204 @@ const BASELINE_SERIES_NAME = 'baseline';
15
19
16
20
type Props = {
17
21
rankedAttributes : SuspectAttributesResult [ 'rankedAttributes' ] ;
22
+ searchQuery : string ;
18
23
} ;
19
24
20
25
// TODO Abdullah Khan: Add virtualization and search to the list of charts
21
- export function Charts ( { rankedAttributes} : Props ) {
26
+ export function Charts ( { rankedAttributes, searchQuery } : Props ) {
22
27
const theme = useTheme ( ) ;
28
+ const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
29
+
30
+ const virtualizer = useVirtualizer ( {
31
+ count : rankedAttributes . length ,
32
+ getScrollElement : ( ) => scrollContainerRef . current ,
33
+ estimateSize : ( ) => 200 ,
34
+ overscan : 5 ,
35
+ } ) ;
36
+
37
+ const virtualItems = virtualizer . getVirtualItems ( ) ;
38
+
23
39
return (
24
- < ChartsWrapper >
25
- { rankedAttributes . map ( attribute => (
26
- < Chart key = { attribute . attributeName } attribute = { attribute } theme = { theme } />
27
- ) ) }
40
+ < ChartsWrapper ref = { scrollContainerRef } >
41
+ < AllItemsContainer height = { virtualizer . getTotalSize ( ) } >
42
+ { virtualItems . map ( item => (
43
+ < VirtualOffset key = { item . index } offset = { item . start } >
44
+ < Chart
45
+ key = { `${ item . key } +${ searchQuery } ` }
46
+ index = { item . index }
47
+ virtualizer = { virtualizer }
48
+ attribute = { rankedAttributes [ item . index ] ! }
49
+ theme = { theme }
50
+ />
51
+ </ VirtualOffset >
52
+ ) ) }
53
+ </ AllItemsContainer >
28
54
</ ChartsWrapper >
29
55
) ;
30
56
}
31
57
58
+ type CohortData = SuspectAttributesResult [ 'rankedAttributes' ] [ number ] [ 'cohort1' ] ;
59
+
60
+ function cohortsToSeriesData (
61
+ cohort1 : CohortData ,
62
+ cohort2 : CohortData
63
+ ) : {
64
+ [ BASELINE_SERIES_NAME ] : Array < { label : string ; value : string } > ;
65
+ [ SELECTED_SERIES_NAME ] : Array < { label : string ; value : string } > ;
66
+ } {
67
+ const cohort1Map = new Map ( cohort1 . map ( ( { label, value} ) => [ label , value ] ) ) ;
68
+ const cohort2Map = new Map ( cohort2 . map ( ( { label, value} ) => [ label , value ] ) ) ;
69
+
70
+ const uniqueLabels = new Set ( [
71
+ ...cohort1 . map ( c => c . label ) ,
72
+ ...cohort2 . map ( c => c . label ) ,
73
+ ] ) ;
74
+
75
+ // From the unique labels, we create two series data objects, one for the selected cohort and one for the baseline cohort.
76
+ // If a label isn't present in either of the cohorts, we assign a value of 0, to that label in the respective series.
77
+ const seriesData = Array . from ( uniqueLabels ) . map ( label => {
78
+ const selectedVal = cohort1Map . get ( label ) ?? '0' ;
79
+ const baselineVal = cohort2Map . get ( label ) ?? '0' ;
80
+
81
+ // We sort by descending value of the selected cohort
82
+ const sortVal = Number ( selectedVal ) ;
83
+
84
+ return {
85
+ label,
86
+ selectedValue : selectedVal ,
87
+ baselineValue : baselineVal ,
88
+ sortValue : sortVal ,
89
+ } ;
90
+ } ) ;
91
+
92
+ seriesData . sort ( ( a , b ) => b . sortValue - a . sortValue ) ;
93
+
94
+ const selectedSeriesData = seriesData . map ( ( { label, selectedValue} ) => ( {
95
+ label,
96
+ value : selectedValue ,
97
+ } ) ) ;
98
+
99
+ const baselineSeriesData = seriesData . map ( ( { label, baselineValue} ) => ( {
100
+ label,
101
+ value : baselineValue ,
102
+ } ) ) ;
103
+
104
+ return {
105
+ [ SELECTED_SERIES_NAME ] : selectedSeriesData ,
106
+ [ BASELINE_SERIES_NAME ] : baselineSeriesData ,
107
+ } ;
108
+ }
109
+
110
+ // TODO Abdullah Khan: This is a temporary function to get the totals of the cohorts. Will be removed
111
+ // once the backend returns the totals.
112
+ function cohortTotals (
113
+ cohort1 : CohortData ,
114
+ cohort2 : CohortData
115
+ ) : {
116
+ [ BASELINE_SERIES_NAME ] : number ;
117
+ [ SELECTED_SERIES_NAME ] : number ;
118
+ } {
119
+ const cohort1Total = cohort1 . reduce ( ( acc , curr ) => acc + Number ( curr . value ) , 0 ) ;
120
+ const cohort2Total = cohort2 . reduce ( ( acc , curr ) => acc + Number ( curr . value ) , 0 ) ;
121
+ return {
122
+ [ SELECTED_SERIES_NAME ] : cohort1Total ,
123
+ [ BASELINE_SERIES_NAME ] : cohort2Total ,
124
+ } ;
125
+ }
126
+
32
127
function Chart ( {
33
128
attribute,
34
129
theme,
130
+ index,
131
+ virtualizer,
35
132
} : {
36
133
attribute : SuspectAttributesResult [ 'rankedAttributes' ] [ number ] ;
134
+ index : number ;
37
135
theme : Theme ;
136
+ virtualizer : Virtualizer < HTMLDivElement , Element > ;
38
137
} ) {
39
138
const chartRef = useRef < ReactEchartsRef > ( null ) ;
40
139
const [ hideLabels , setHideLabels ] = useState ( false ) ;
41
140
42
141
const cohort1Color = theme . chart . getColorPalette ( 0 ) ?. [ 0 ] ;
43
142
const cohort2Color = '#dddddd' ;
44
143
144
+ const seriesData = useMemo (
145
+ ( ) => cohortsToSeriesData ( attribute . cohort1 , attribute . cohort2 ) ,
146
+ [ attribute . cohort1 , attribute . cohort2 ]
147
+ ) ;
148
+
149
+ const seriesTotals = useMemo (
150
+ ( ) => cohortTotals ( attribute . cohort1 , attribute . cohort2 ) ,
151
+ [ attribute . cohort1 , attribute . cohort2 ]
152
+ ) ;
153
+
154
+ const valueFormatter = useCallback (
155
+ ( _value : number , label ?: string , seriesParams ?: CallbackDataParams ) => {
156
+ const data = Number ( seriesParams ?. data ) ;
157
+ const total = seriesTotals [ label as keyof typeof seriesTotals ] ;
158
+
159
+ if ( total === 0 ) {
160
+ return '\u2014' ;
161
+ }
162
+
163
+ const percentage = ( data / total ) * 100 ;
164
+ return `${ percentage . toFixed ( 1 ) } %` ;
165
+ } ,
166
+ [ seriesTotals ]
167
+ ) ;
168
+
169
+ const formatAxisLabel = useCallback (
170
+ (
171
+ _value : number ,
172
+ _isTimestamp : boolean ,
173
+ _utc : boolean ,
174
+ _showTimeInTooltip : boolean ,
175
+ _addSecondsToTimeFormat : boolean ,
176
+ _bucketSize : number | undefined ,
177
+ seriesParamsOrParam : TooltipComponentFormatterCallbackParams
178
+ ) => {
179
+ if ( ! Array . isArray ( seriesParamsOrParam ) ) {
180
+ return '\u2014' ;
181
+ }
182
+
183
+ const selectedParam = seriesParamsOrParam . find (
184
+ s => s . seriesName === SELECTED_SERIES_NAME
185
+ ) ;
186
+ const baselineParam = seriesParamsOrParam . find (
187
+ s => s . seriesName === BASELINE_SERIES_NAME
188
+ ) ;
189
+
190
+ if ( ! selectedParam || ! baselineParam ) {
191
+ throw new Error ( 'selectedParam or baselineParam is not defined' ) ;
192
+ }
193
+
194
+ const selectedTotal =
195
+ seriesTotals [ selectedParam ?. seriesName as keyof typeof seriesTotals ] ;
196
+ const selectedData = Number ( selectedParam ?. data ) ;
197
+ const selectedPercentage =
198
+ selectedTotal === 0 ? 0 : ( selectedData / selectedTotal ) * 100 ;
199
+
200
+ const baselineTotal =
201
+ seriesTotals [ baselineParam ?. seriesName as keyof typeof seriesTotals ] ;
202
+ const baselineData = Number ( baselineParam ?. data ) ;
203
+ const baselinePercentage =
204
+ baselineTotal === 0 ? 0 : ( baselineData / baselineTotal ) * 100 ;
205
+
206
+ const isDifferent = selectedPercentage . toFixed ( 1 ) !== baselinePercentage . toFixed ( 1 ) ;
207
+
208
+ const status = isDifferent
209
+ ? { adjective : 'different' , message : 'This is suspicious.' }
210
+ : { adjective : 'similar' , message : 'Nothing unusual here.' } ;
211
+
212
+ const name = selectedParam ?. name ?? baselineParam ?. name ?? '' ;
213
+ const truncatedName = name . length > 300 ? `${ name . slice ( 0 , 300 ) } ...` : name ;
214
+
215
+ return `<div style="max-width: 200px; white-space: normal; word-wrap: break-word; line-height: 1.2;">${ truncatedName } <span style="color: ${ theme . textColor } ;">is <strong>${ status . adjective } </strong> ${ isDifferent ? 'between' : 'across' } selected and baseline data. ${ status . message } </span></div>` ;
216
+ } ,
217
+ [ seriesTotals , theme . textColor ]
218
+ ) ;
219
+
45
220
useLayoutEffect ( ( ) => {
46
221
const chartContainer = chartRef . current ?. getEchartsInstance ( ) . getDom ( ) ;
47
222
if ( ! chartContainer ) return ;
@@ -62,87 +237,103 @@ function Chart({
62
237
} , [ attribute ] ) ;
63
238
64
239
return (
65
- < ChartWrapper >
66
- < ChartTitle > { attribute . attributeName } </ ChartTitle >
67
- < BaseChart
68
- ref = { chartRef }
69
- autoHeightResize
70
- isGroupedByDate = { false }
71
- tooltip = { {
72
- trigger : 'axis' ,
73
- confine : true ,
74
- } }
75
- grid = { {
76
- left : 2 ,
77
- right : 8 ,
78
- containLabel : true ,
79
- } }
80
- xAxis = { {
81
- show : true ,
82
- type : 'category' ,
83
- data : attribute . cohort1 . map ( cohort => cohort . label ) ,
84
- truncate : 14 ,
85
- axisLabel : hideLabels
86
- ? { show : false }
87
- : {
88
- hideOverlap : true ,
89
- showMaxLabel : false ,
90
- showMinLabel : false ,
91
- color : '#000' ,
92
- interval : 0 ,
93
- formatter : ( value : string ) => value ,
240
+ < div ref = { virtualizer . measureElement } data-index = { index } >
241
+ < ChartWrapper >
242
+ < ChartTitle > { attribute . attributeName } </ ChartTitle >
243
+ < BaseChart
244
+ ref = { chartRef }
245
+ autoHeightResize
246
+ isGroupedByDate = { false }
247
+ tooltip = { {
248
+ trigger : 'axis' ,
249
+ appendToBody : true ,
250
+ renderMode : 'html' ,
251
+ valueFormatter,
252
+ formatAxisLabel,
253
+ } }
254
+ grid = { {
255
+ left : 2 ,
256
+ right : 8 ,
257
+ containLabel : true ,
258
+ } }
259
+ xAxis = { {
260
+ show : true ,
261
+ type : 'category' ,
262
+ data : seriesData [ SELECTED_SERIES_NAME ] . map ( cohort => cohort . label ) ,
263
+ truncate : 14 ,
264
+ axisLabel : hideLabels
265
+ ? { show : false }
266
+ : {
267
+ hideOverlap : true ,
268
+ showMaxLabel : false ,
269
+ showMinLabel : false ,
270
+ color : '#000' ,
271
+ interval : 0 ,
272
+ formatter : ( value : string ) => value ,
273
+ } ,
274
+ } }
275
+ yAxis = { {
276
+ type : 'value' ,
277
+ axisLabel : {
278
+ show : false ,
279
+ width : 0 ,
280
+ } ,
281
+ } }
282
+ series = { [
283
+ {
284
+ type : 'bar' ,
285
+ data : seriesData [ SELECTED_SERIES_NAME ] . map ( cohort => cohort . value ) ,
286
+ name : SELECTED_SERIES_NAME ,
287
+ itemStyle : {
288
+ color : cohort1Color ,
94
289
} ,
95
- } }
96
- yAxis = { {
97
- type : 'value' ,
98
- axisLabel : {
99
- show : false ,
100
- width : 0 ,
101
- } ,
102
- } }
103
- series = { [
104
- {
105
- type : 'bar' ,
106
- data : attribute . cohort1 . map ( cohort => cohort . value ) ,
107
- name : SELECTED_SERIES_NAME ,
108
- itemStyle : {
109
- color : cohort1Color ,
290
+ barMaxWidth : MAX_BAR_WIDTH ,
291
+ animation : false ,
110
292
} ,
111
- barMaxWidth : MAX_BAR_WIDTH ,
112
- animation : false ,
113
- } ,
114
- {
115
- type : 'bar' ,
116
- data : attribute . cohort2 . map ( cohort => cohort . value ) ,
117
- name : BASELINE_SERIES_NAME ,
118
- itemStyle : {
119
- color : cohort2Color ,
293
+ {
294
+ type : 'bar' ,
295
+ data : seriesData [ BASELINE_SERIES_NAME ] . map ( cohort => cohort . value ) ,
296
+ name : BASELINE_SERIES_NAME ,
297
+ itemStyle : {
298
+ color : cohort2Color ,
299
+ } ,
300
+ barMaxWidth : MAX_BAR_WIDTH ,
301
+ animation : false ,
120
302
} ,
121
- barMaxWidth : MAX_BAR_WIDTH ,
122
- animation : false ,
123
- } ,
124
- ] }
125
- />
126
- </ ChartWrapper >
303
+ ] }
304
+ />
305
+ </ ChartWrapper >
306
+ </ div >
127
307
) ;
128
308
}
129
309
130
310
const ChartsWrapper = styled ( 'div' ) `
131
- flex: 1 ;
311
+ height: 100% ;
132
312
overflow: auto;
133
313
overflow-y: scroll;
134
314
overscroll-behavior: none;
135
315
` ;
136
316
317
+ const AllItemsContainer = styled ( 'div' ) < { height : number } > `
318
+ position: relative;
319
+ width: 100%;
320
+ height: ${ p => p . height } px;
321
+ ` ;
322
+
323
+ const VirtualOffset = styled ( 'div' ) < { offset : number } > `
324
+ position: absolute;
325
+ top: 0;
326
+ left: 0;
327
+ width: 100%;
328
+ transform: translateY(${ p => p . offset } px);
329
+ ` ;
330
+
137
331
const ChartWrapper = styled ( 'div' ) `
138
332
display: flex;
139
333
flex-direction: column;
140
334
height: 200px;
141
- padding: ${ space ( 2 ) } ${ space ( 2 ) } 0 ${ space ( 2 ) } ;
142
-
143
- &:not(:last-child) {
144
- border-bottom: 1px solid ${ p => p . theme . border } ;
145
- }
335
+ padding-top: ${ space ( 1.5 ) } ;
336
+ border-top: 1px solid ${ p => p . theme . border } ;
146
337
` ;
147
338
148
339
const ChartTitle = styled ( 'div' ) `
0 commit comments