[Uptime] Scale waterfall chart to handle large number of requests (#88338)
This commit is contained in:
parent
eb4cb3d1dc
commit
7d14229519
|
@ -13,3 +13,6 @@ export const SIDEBAR_GROW_SIZE = 2;
|
||||||
// Axis height
|
// Axis height
|
||||||
// NOTE: This isn't a perfect solution - changes in font size etc within charts could change the ideal height here.
|
// NOTE: This isn't a perfect solution - changes in font size etc within charts could change the ideal height here.
|
||||||
export const FIXED_AXIS_HEIGHT = 32;
|
export const FIXED_AXIS_HEIGHT = 32;
|
||||||
|
|
||||||
|
// number of items to display in canvas, since canvas can only have limited size
|
||||||
|
export const CANVAS_MAX_ITEMS = 150;
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui';
|
import { EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui';
|
||||||
|
import { FIXED_AXIS_HEIGHT } from './constants';
|
||||||
|
|
||||||
const OuterContainer = styled.div`
|
const OuterContainer = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -29,10 +30,12 @@ const FirstChunk = styled.span`
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
line-height: ${FIXED_AXIS_HEIGHT}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LastChunk = styled.span`
|
const LastChunk = styled.span`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
line-height: ${FIXED_AXIS_HEIGHT}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const getChunks = (text: string) => {
|
export const getChunks = (text: string) => {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EuiFlexItem } from '@elastic/eui';
|
import { EuiFlexItem } from '@elastic/eui';
|
||||||
import { SIDEBAR_GROW_SIZE } from './constants';
|
import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants';
|
||||||
import { IWaterfallContext } from '../context/waterfall_chart';
|
import { IWaterfallContext } from '../context/waterfall_chart';
|
||||||
import {
|
import {
|
||||||
WaterfallChartSidebarContainer,
|
WaterfallChartSidebarContainer,
|
||||||
|
@ -18,14 +18,13 @@ import { WaterfallChartProps } from './waterfall_chart';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
items: Required<IWaterfallContext>['sidebarItems'];
|
items: Required<IWaterfallContext>['sidebarItems'];
|
||||||
height: number;
|
|
||||||
render: Required<WaterfallChartProps>['renderSidebarItem'];
|
render: Required<WaterfallChartProps>['renderSidebarItem'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ items, height, render }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ items, render }) => {
|
||||||
return (
|
return (
|
||||||
<EuiFlexItem grow={SIDEBAR_GROW_SIZE}>
|
<EuiFlexItem grow={SIDEBAR_GROW_SIZE}>
|
||||||
<WaterfallChartSidebarContainer height={height}>
|
<WaterfallChartSidebarContainer height={items.length * FIXED_AXIS_HEIGHT}>
|
||||||
<WaterfallChartSidebarContainerInnerPanel paddingSize="none">
|
<WaterfallChartSidebarContainerInnerPanel paddingSize="none">
|
||||||
<WaterfallChartSidebarContainerFlexGroup
|
<WaterfallChartSidebarContainerFlexGroup
|
||||||
direction="column"
|
direction="column"
|
||||||
|
|
|
@ -47,6 +47,7 @@ export const WaterfallChartFixedTopContainerSidebarCover = euiStyled(EuiPanel)`
|
||||||
|
|
||||||
export const WaterfallChartFixedAxisContainer = euiStyled.div`
|
export const WaterfallChartFixedAxisContainer = euiStyled.div`
|
||||||
height: ${FIXED_AXIS_HEIGHT}px;
|
height: ${FIXED_AXIS_HEIGHT}px;
|
||||||
|
z-index: ${(props) => props.theme.eui.euiZLevel4};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface WaterfallChartSidebarContainer {
|
interface WaterfallChartSidebarContainer {
|
||||||
|
@ -54,7 +55,7 @@ interface WaterfallChartSidebarContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WaterfallChartSidebarContainer = euiStyled.div<WaterfallChartSidebarContainer>`
|
export const WaterfallChartSidebarContainer = euiStyled.div<WaterfallChartSidebarContainer>`
|
||||||
height: ${(props) => `${props.height - FIXED_AXIS_HEIGHT}px`};
|
height: ${(props) => `${props.height}px`};
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -76,12 +77,14 @@ export const WaterfallChartSidebarFlexItem = euiStyled(EuiFlexItem)`
|
||||||
|
|
||||||
interface WaterfallChartChartContainer {
|
interface WaterfallChartChartContainer {
|
||||||
height: number;
|
height: number;
|
||||||
|
chartIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WaterfallChartChartContainer = euiStyled.div<WaterfallChartChartContainer>`
|
export const WaterfallChartChartContainer = euiStyled.div<WaterfallChartChartContainer>`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: ${(props) => `${props.height}px`};
|
height: ${(props) => `${props.height + FIXED_AXIS_HEIGHT - 4}px`};
|
||||||
margin-top: -${FIXED_AXIS_HEIGHT}px;
|
margin-top: -${FIXED_AXIS_HEIGHT - 4}px;
|
||||||
|
z-index: ${(props) => Math.round(props.theme.eui.euiZLevel3 / (props.chartIndex + 1))};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const WaterfallChartLegendContainer = euiStyled.div`
|
export const WaterfallChartLegendContainer = euiStyled.div`
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useBarCharts } from './use_bar_charts';
|
||||||
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
|
import { IWaterfallContext } from '../context/waterfall_chart';
|
||||||
|
import { CANVAS_MAX_ITEMS } from './constants';
|
||||||
|
|
||||||
|
const generateTestData = (): IWaterfallContext['data'] => {
|
||||||
|
const numberOfItems = 1000;
|
||||||
|
|
||||||
|
const data: IWaterfallContext['data'] = [];
|
||||||
|
const testItem = {
|
||||||
|
x: 0,
|
||||||
|
y0: 0,
|
||||||
|
y: 4.345000023022294,
|
||||||
|
config: {
|
||||||
|
colour: '#b9a888',
|
||||||
|
showTooltip: true,
|
||||||
|
tooltipProps: { value: 'Queued / Blocked: 4.345ms', colour: '#b9a888' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfItems; i++) {
|
||||||
|
data.push(
|
||||||
|
{
|
||||||
|
...testItem,
|
||||||
|
x: i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...testItem,
|
||||||
|
x: i,
|
||||||
|
y0: 7,
|
||||||
|
y: 25,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useBarChartsHooks', () => {
|
||||||
|
it('returns result as expected', () => {
|
||||||
|
const { result, rerender } = renderHook((props) => useBarCharts(props), {
|
||||||
|
initialProps: { data: [] as IWaterfallContext['data'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(0);
|
||||||
|
const newData = generateTestData();
|
||||||
|
|
||||||
|
rerender({ data: newData });
|
||||||
|
|
||||||
|
// Thousands items will result in 7 Canvas
|
||||||
|
expect(result.current.length).toBe(7);
|
||||||
|
|
||||||
|
const firstChartItems = result.current[0];
|
||||||
|
const lastChartItems = result.current[4];
|
||||||
|
|
||||||
|
// first chart items last item should be x 199, since we only display 150 items
|
||||||
|
expect(firstChartItems[firstChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS - 1);
|
||||||
|
|
||||||
|
// since here are 5 charts, last chart first item should be x 800
|
||||||
|
expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4);
|
||||||
|
expect(lastChartItems[lastChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS * 5 - 1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { IWaterfallContext } from '../context/waterfall_chart';
|
||||||
|
import { CANVAS_MAX_ITEMS } from './constants';
|
||||||
|
|
||||||
|
export interface UseBarHookProps {
|
||||||
|
data: IWaterfallContext['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBarCharts = ({ data = [] }: UseBarHookProps) => {
|
||||||
|
const [charts, setCharts] = useState<Array<IWaterfallContext['data']>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
let chartIndex = 1;
|
||||||
|
|
||||||
|
const firstCanvasItems = data.filter((item) => item.x <= CANVAS_MAX_ITEMS);
|
||||||
|
|
||||||
|
const chartsN: Array<IWaterfallContext['data']> = [firstCanvasItems];
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
// Subtract 1 to account for x value starting from 0
|
||||||
|
if (item.x === CANVAS_MAX_ITEMS * chartIndex && !chartsN[item.x / CANVAS_MAX_ITEMS]) {
|
||||||
|
chartsN.push([]);
|
||||||
|
chartIndex++;
|
||||||
|
}
|
||||||
|
chartsN[chartIndex - 1].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
setCharts(chartsN);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return charts;
|
||||||
|
};
|
|
@ -33,9 +33,10 @@ import {
|
||||||
WaterfallChartTooltip,
|
WaterfallChartTooltip,
|
||||||
} from './styles';
|
} from './styles';
|
||||||
import { WaterfallData } from '../types';
|
import { WaterfallData } from '../types';
|
||||||
import { BAR_HEIGHT, MAIN_GROW_SIZE, SIDEBAR_GROW_SIZE, FIXED_AXIS_HEIGHT } from './constants';
|
import { BAR_HEIGHT, CANVAS_MAX_ITEMS, MAIN_GROW_SIZE, SIDEBAR_GROW_SIZE } from './constants';
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
import { Legend } from './legend';
|
import { Legend } from './legend';
|
||||||
|
import { useBarCharts } from './use_bar_charts';
|
||||||
|
|
||||||
const Tooltip = (tooltipInfo: TooltipInfo) => {
|
const Tooltip = (tooltipInfo: TooltipInfo) => {
|
||||||
const { data, renderTooltipItem } = useWaterfallContext();
|
const { data, renderTooltipItem } = useWaterfallContext();
|
||||||
|
@ -69,20 +70,11 @@ export interface WaterfallChartProps {
|
||||||
fullHeight?: boolean;
|
fullHeight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUniqueBars = (data: WaterfallData) => {
|
const getChartHeight = (data: WaterfallData, ind: number): number => {
|
||||||
return (data ?? []).reduce<Set<number>>((acc, item) => {
|
// We get the last item x(number of bars) and adds 1 to cater for 0 index
|
||||||
if (!acc.has(item.x)) {
|
return (data[data.length - 1]?.x + 1 - ind * CANVAS_MAX_ITEMS) * BAR_HEIGHT;
|
||||||
acc.add(item.x);
|
|
||||||
return acc;
|
|
||||||
} else {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
}, new Set());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChartHeight = (data: WaterfallData): number =>
|
|
||||||
getUniqueBars(data).size * BAR_HEIGHT + FIXED_AXIS_HEIGHT;
|
|
||||||
|
|
||||||
export const WaterfallChart = ({
|
export const WaterfallChart = ({
|
||||||
tickFormat,
|
tickFormat,
|
||||||
domain,
|
domain,
|
||||||
|
@ -94,10 +86,6 @@ export const WaterfallChart = ({
|
||||||
}: WaterfallChartProps) => {
|
}: WaterfallChartProps) => {
|
||||||
const { data, sidebarItems, legendItems } = useWaterfallContext();
|
const { data, sidebarItems, legendItems } = useWaterfallContext();
|
||||||
|
|
||||||
const generatedHeight = useMemo(() => {
|
|
||||||
return getChartHeight(data);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
|
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => {
|
||||||
|
@ -108,10 +96,8 @@ export const WaterfallChart = ({
|
||||||
|
|
||||||
const [height, setHeight] = useState<string>(maxHeight);
|
const [height, setHeight] = useState<string>(maxHeight);
|
||||||
|
|
||||||
const shouldRenderSidebar =
|
const shouldRenderSidebar = !!(sidebarItems && sidebarItems.length > 0 && renderSidebarItem);
|
||||||
sidebarItems && sidebarItems.length > 0 && renderSidebarItem ? true : false;
|
const shouldRenderLegend = !!(legendItems && legendItems.length > 0 && renderLegendItem);
|
||||||
const shouldRenderLegend =
|
|
||||||
legendItems && legendItems.length > 0 && renderLegendItem ? true : false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fullHeight && chartWrapperDivRef.current) {
|
if (fullHeight && chartWrapperDivRef.current) {
|
||||||
|
@ -120,6 +106,8 @@ export const WaterfallChart = ({
|
||||||
}
|
}
|
||||||
}, [chartWrapperDivRef, fullHeight]);
|
}, [chartWrapperDivRef, fullHeight]);
|
||||||
|
|
||||||
|
const chartsToDisplay = useBarCharts({ data });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WaterfallChartOuterContainer height={height}>
|
<WaterfallChartOuterContainer height={height}>
|
||||||
<>
|
<>
|
||||||
|
@ -174,44 +162,48 @@ export const WaterfallChart = ({
|
||||||
style={{ paddingTop: '10px' }}
|
style={{ paddingTop: '10px' }}
|
||||||
ref={chartWrapperDivRef}
|
ref={chartWrapperDivRef}
|
||||||
>
|
>
|
||||||
{shouldRenderSidebar && (
|
{shouldRenderSidebar && <Sidebar items={sidebarItems!} render={renderSidebarItem!} />}
|
||||||
<Sidebar items={sidebarItems!} height={generatedHeight} render={renderSidebarItem!} />
|
|
||||||
)}
|
|
||||||
<EuiFlexItem grow={shouldRenderSidebar ? MAIN_GROW_SIZE : true}>
|
<EuiFlexItem grow={shouldRenderSidebar ? MAIN_GROW_SIZE : true}>
|
||||||
<WaterfallChartChartContainer height={generatedHeight}>
|
{chartsToDisplay.map((chartData, ind) => (
|
||||||
<Chart className="data-chart">
|
<WaterfallChartChartContainer
|
||||||
<Settings
|
height={getChartHeight(chartData, ind)}
|
||||||
showLegend={false}
|
chartIndex={ind}
|
||||||
rotation={90}
|
key={ind}
|
||||||
tooltip={{ customTooltip: Tooltip }}
|
>
|
||||||
theme={theme}
|
<Chart className="data-chart">
|
||||||
/>
|
<Settings
|
||||||
|
showLegend={false}
|
||||||
|
rotation={90}
|
||||||
|
tooltip={{ customTooltip: Tooltip }}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
|
||||||
<Axis
|
<Axis
|
||||||
id="time"
|
id="time"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
tickFormat={tickFormat}
|
tickFormat={tickFormat}
|
||||||
domain={domain}
|
domain={domain}
|
||||||
showGridLines={true}
|
showGridLines={true}
|
||||||
style={{
|
style={{
|
||||||
axisLine: {
|
axisLine: {
|
||||||
visible: false,
|
visible: false,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BarSeries
|
<BarSeries
|
||||||
id="waterfallItems"
|
id="waterfallItems"
|
||||||
xScaleType={ScaleType.Linear}
|
xScaleType={ScaleType.Linear}
|
||||||
yScaleType={ScaleType.Linear}
|
yScaleType={ScaleType.Linear}
|
||||||
xAccessor="x"
|
xAccessor="x"
|
||||||
yAccessors={['y']}
|
yAccessors={['y']}
|
||||||
y0Accessors={['y0']}
|
y0Accessors={['y0']}
|
||||||
styleAccessor={barStyleAccessor}
|
styleAccessor={barStyleAccessor}
|
||||||
data={data}
|
data={chartData}
|
||||||
/>
|
/>
|
||||||
</Chart>
|
</Chart>
|
||||||
</WaterfallChartChartContainer>
|
</WaterfallChartChartContainer>
|
||||||
|
))}
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
{shouldRenderLegend && <Legend items={legendItems!} render={renderLegendItem!} />}
|
{shouldRenderLegend && <Legend items={legendItems!} render={renderLegendItem!} />}
|
||||||
|
|
Loading…
Reference in a new issue