Skip to content

Commit cc1f2d4

Browse files
Abdkhan14Abdullah Khan
andauthored
feat(explore-sus-attrs): Virtualizing list of charts. (#95434)
The functionality in this PR is under the flag `performance-spans-suspect-attributes`: - Virtualizes the list of charts - Adds search - Fixes tooltip (we couldn't append tooltip to `document.body` without virtualization. It's too expensive, when we have hundreds of charts, otherwise). <img width="848" height="805" alt="Screenshot 2025-07-14 at 1 14 35 AM" src="https://github.com/user-attachments/assets/74946b5e-c946-42bf-84c9-4bc406d700fe" /> --------- Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
1 parent 34775cc commit cc1f2d4

File tree

2 files changed

+178
-76
lines changed

2 files changed

+178
-76
lines changed

static/app/views/explore/components/suspectTags/charts.tsx

Lines changed: 118 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
22
import type {Theme} from '@emotion/react';
33
import {useTheme} from '@emotion/react';
44
import styled from '@emotion/styled';
5+
import type {Virtualizer} from '@tanstack/react-virtual';
6+
import {useVirtualizer} from '@tanstack/react-virtual';
57
import type {TooltipComponentFormatterCallbackParams} from 'echarts';
68
import type {CallbackDataParams} from 'echarts/types/dist/shared';
79

@@ -17,16 +19,38 @@ const BASELINE_SERIES_NAME = 'baseline';
1719

1820
type Props = {
1921
rankedAttributes: SuspectAttributesResult['rankedAttributes'];
22+
searchQuery: string;
2023
};
2124

2225
// TODO Abdullah Khan: Add virtualization and search to the list of charts
23-
export function Charts({rankedAttributes}: Props) {
26+
export function Charts({rankedAttributes, searchQuery}: Props) {
2427
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+
2539
return (
26-
<ChartsWrapper>
27-
{rankedAttributes.map(attribute => (
28-
<Chart key={attribute.attributeName} attribute={attribute} theme={theme} />
29-
))}
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>
3054
</ChartsWrapper>
3155
);
3256
}
@@ -103,9 +127,13 @@ function cohortTotals(
103127
function Chart({
104128
attribute,
105129
theme,
130+
index,
131+
virtualizer,
106132
}: {
107133
attribute: SuspectAttributesResult['rankedAttributes'][number];
134+
index: number;
108135
theme: Theme;
136+
virtualizer: Virtualizer<HTMLDivElement, Element>;
109137
}) {
110138
const chartRef = useRef<ReactEchartsRef>(null);
111139
const [hideLabels, setHideLabels] = useState(false);
@@ -181,7 +209,10 @@ function Chart({
181209
? {adjective: 'different', message: 'This is suspicious.'}
182210
: {adjective: 'similar', message: 'Nothing unusual here.'};
183211

184-
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>`;
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>`;
185216
},
186217
[seriesTotals, theme.textColor]
187218
);
@@ -206,88 +237,103 @@ function Chart({
206237
}, [attribute]);
207238

208239
return (
209-
<ChartWrapper>
210-
<ChartTitle>{attribute.attributeName}</ChartTitle>
211-
<BaseChart
212-
ref={chartRef}
213-
autoHeightResize
214-
isGroupedByDate={false}
215-
tooltip={{
216-
renderMode: 'html',
217-
valueFormatter,
218-
formatAxisLabel,
219-
}}
220-
grid={{
221-
left: 2,
222-
right: 8,
223-
containLabel: true,
224-
}}
225-
xAxis={{
226-
show: true,
227-
type: 'category',
228-
data: seriesData[SELECTED_SERIES_NAME].map(cohort => cohort.label),
229-
truncate: 14,
230-
axisLabel: hideLabels
231-
? {show: false}
232-
: {
233-
hideOverlap: true,
234-
showMaxLabel: false,
235-
showMinLabel: false,
236-
color: '#000',
237-
interval: 0,
238-
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,
239289
},
240-
}}
241-
yAxis={{
242-
type: 'value',
243-
axisLabel: {
244-
show: false,
245-
width: 0,
246-
},
247-
}}
248-
series={[
249-
{
250-
type: 'bar',
251-
data: seriesData[SELECTED_SERIES_NAME].map(cohort => cohort.value),
252-
name: SELECTED_SERIES_NAME,
253-
itemStyle: {
254-
color: cohort1Color,
290+
barMaxWidth: MAX_BAR_WIDTH,
291+
animation: false,
255292
},
256-
barMaxWidth: MAX_BAR_WIDTH,
257-
animation: false,
258-
},
259-
{
260-
type: 'bar',
261-
data: seriesData[BASELINE_SERIES_NAME].map(cohort => cohort.value),
262-
name: BASELINE_SERIES_NAME,
263-
itemStyle: {
264-
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,
265302
},
266-
barMaxWidth: MAX_BAR_WIDTH,
267-
animation: false,
268-
},
269-
]}
270-
/>
271-
</ChartWrapper>
303+
]}
304+
/>
305+
</ChartWrapper>
306+
</div>
272307
);
273308
}
274309

275310
const ChartsWrapper = styled('div')`
276-
flex: 1;
311+
height: 100%;
277312
overflow: auto;
278313
overflow-y: scroll;
279314
overscroll-behavior: none;
280315
`;
281316

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+
282331
const ChartWrapper = styled('div')`
283332
display: flex;
284333
flex-direction: column;
285334
height: 200px;
286-
padding: ${space(2)} ${space(2)} 0 ${space(2)};
287-
288-
&:not(:last-child) {
289-
border-bottom: 1px solid ${p => p.theme.border};
290-
}
335+
padding-top: ${space(1.5)};
336+
border-top: 1px solid ${p => p.theme.border};
291337
`;
292338

293339
const ChartTitle = styled('div')`

static/app/views/explore/components/suspectTags/drawer.tsx

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import {Fragment, useMemo, useState} from 'react';
12
import styled from '@emotion/styled';
23

34
import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
45
import LoadingError from 'sentry/components/loadingError';
56
import LoadingIndicator from 'sentry/components/loadingIndicator';
7+
import BaseSearchBar from 'sentry/components/searchBar';
68
import {t} from 'sentry/locale';
79
import {space} from 'sentry/styles/space';
10+
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
811
import type {ChartInfo} from 'sentry/views/explore/charts';
912
import {Charts} from 'sentry/views/explore/components/suspectTags/charts';
1013
import type {BoxSelectOptions} from 'sentry/views/explore/hooks/useChartBoxSelect';
@@ -17,6 +20,29 @@ type Props = {
1720

1821
export function Drawer({boxSelectOptions, chartInfo}: Props) {
1922
const {data, isLoading, isError} = useSuspectAttributes({boxSelectOptions, chartInfo});
23+
const [searchQuery, setSearchQuery] = useState('');
24+
25+
const filteredRankedAttributes = useMemo(() => {
26+
const attrs = data?.rankedAttributes;
27+
if (!attrs) {
28+
return [];
29+
}
30+
31+
if (!searchQuery.trim()) {
32+
return attrs;
33+
}
34+
35+
const searchFor = searchQuery.toLocaleLowerCase().trim();
36+
37+
return attrs.filter(attr =>
38+
attr.attributeName.toLocaleLowerCase().trim().includes(searchFor)
39+
);
40+
}, [searchQuery, data?.rankedAttributes]);
41+
42+
// We use the search query as a key to virtual list items, to correctly re-mount
43+
// charts that were invisible before the user searched for it. Debouncing the search
44+
// query here to ensure smooth typing, by delaying the re-mounts a little as the user types.
45+
const debouncedSearchQuery = useDebouncedValue(searchQuery, 100);
2046

2147
return (
2248
<DrawerContainer>
@@ -33,26 +59,48 @@ export function Drawer({boxSelectOptions, chartInfo}: Props) {
3359
) : isError ? (
3460
<LoadingError message={t('Failed to load suspect attributes')} />
3561
) : (
36-
<Charts rankedAttributes={data!.rankedAttributes} />
62+
<Fragment>
63+
<StyledBaseSearchBar
64+
placeholder={t('Search keys')}
65+
onChange={query => setSearchQuery(query)}
66+
query={searchQuery}
67+
size="sm"
68+
/>
69+
{filteredRankedAttributes.length > 0 ? (
70+
<Charts
71+
rankedAttributes={filteredRankedAttributes}
72+
searchQuery={debouncedSearchQuery}
73+
/>
74+
) : (
75+
<NoAttributesMessage>
76+
{t('No matching attributes found')}
77+
</NoAttributesMessage>
78+
)}
79+
</Fragment>
3780
)}
3881
</StyledDrawerBody>
3982
</DrawerContainer>
4083
);
4184
}
4285

4386
const Title = styled('h4')`
44-
margin: 0;
87+
margin-bottom: ${space(0.5)};
4588
flex-shrink: 0;
4689
`;
4790

48-
const SubTitle = styled('span')``;
91+
const StyledBaseSearchBar = styled(BaseSearchBar)`
92+
margin-bottom: ${space(1.5)};
93+
`;
94+
95+
const SubTitle = styled('span')`
96+
margin-bottom: ${space(3)};
97+
`;
4998

5099
const StyledDrawerBody = styled(DrawerBody)`
51100
flex: 1;
52101
min-height: 0;
53102
display: flex;
54103
flex-direction: column;
55-
gap: ${space(1)};
56104
`;
57105

58106
const DrawerContainer = styled('div')`
@@ -64,3 +112,11 @@ const DrawerContainer = styled('div')`
64112
flex-shrink: 0;
65113
}
66114
`;
115+
116+
const NoAttributesMessage = styled('div')`
117+
display: flex;
118+
justify-content: center;
119+
align-items: center;
120+
margin-top: ${space(4)};
121+
color: ${p => p.theme.subText};
122+
`;

0 commit comments

Comments
 (0)