Skip to content

Commit f91f02d

Browse files
Abdkhan14Abdullah Khan
andauthored
feat(explore-suspect-attrs): Iterating on series data, sorting or dat… (#95401)
…a and tooltip `cohortsToSeriesData` takes the cohorts and assigns a value of '0' to the chart series data if a label is not present in a cohort. Example: `cohort1: {'prod': 1, 'local': 2}` and `cohort2: {'local': 3}` -----> `series_cohort_1: [{label: 'prod', value: '1'},{label: 'local', value: '2'}]` and `series_cohort_2: [{label: 'prod', value: '0'},{label: 'local', value: '3'}]` Note: Removed confining of tooltip, it will be cut off at the boundaries. Will set appendToBody:true (which pushes the tooltip up to document.body) along with the virtualization PR, otherwise the screen freezes trying to do it for too many charts. --------- Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
1 parent 9f14e5d commit f91f02d

File tree

2 files changed

+322
-75
lines changed

2 files changed

+322
-75
lines changed

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

Lines changed: 262 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import {useLayoutEffect, useRef, useState} from 'react';
1+
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';
7+
import type {TooltipComponentFormatterCallbackParams} from 'echarts';
8+
import type {CallbackDataParams} from 'echarts/types/dist/shared';
59

610
import BaseChart from 'sentry/components/charts/baseChart';
711
import {space} from 'sentry/styles/space';
@@ -15,33 +19,204 @@ const BASELINE_SERIES_NAME = 'baseline';
1519

1620
type Props = {
1721
rankedAttributes: SuspectAttributesResult['rankedAttributes'];
22+
searchQuery: string;
1823
};
1924

2025
// 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) {
2227
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+
2339
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>
2854
</ChartsWrapper>
2955
);
3056
}
3157

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+
32127
function Chart({
33128
attribute,
34129
theme,
130+
index,
131+
virtualizer,
35132
}: {
36133
attribute: SuspectAttributesResult['rankedAttributes'][number];
134+
index: number;
37135
theme: Theme;
136+
virtualizer: Virtualizer<HTMLDivElement, Element>;
38137
}) {
39138
const chartRef = useRef<ReactEchartsRef>(null);
40139
const [hideLabels, setHideLabels] = useState(false);
41140

42141
const cohort1Color = theme.chart.getColorPalette(0)?.[0];
43142
const cohort2Color = '#dddddd';
44143

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+
45220
useLayoutEffect(() => {
46221
const chartContainer = chartRef.current?.getEchartsInstance().getDom();
47222
if (!chartContainer) return;
@@ -62,87 +237,103 @@ function Chart({
62237
}, [attribute]);
63238

64239
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,
94289
},
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,
110292
},
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,
120302
},
121-
barMaxWidth: MAX_BAR_WIDTH,
122-
animation: false,
123-
},
124-
]}
125-
/>
126-
</ChartWrapper>
303+
]}
304+
/>
305+
</ChartWrapper>
306+
</div>
127307
);
128308
}
129309

130310
const ChartsWrapper = styled('div')`
131-
flex: 1;
311+
height: 100%;
132312
overflow: auto;
133313
overflow-y: scroll;
134314
overscroll-behavior: none;
135315
`;
136316

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+
137331
const ChartWrapper = styled('div')`
138332
display: flex;
139333
flex-direction: column;
140334
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};
146337
`;
147338

148339
const ChartTitle = styled('div')`

0 commit comments

Comments
 (0)