feat(cicd-statistics): Added zoom capability to the charts
Since loading of build information can take a long time, being able to zoom "client-side", using the fetched data, is very useful. This is built using a provider (ZoomProvider) which keeps zoom state, simplifies rendering a gray area while zooming and has a filter-function which can filter only values within the zoomed time range. Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Fragment, useMemo } from 'react';
|
||||
import React, { CSSProperties, Fragment, useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { useZoom, useZoomArea } from './zoom';
|
||||
import { CicdDefaults, statusTypes } from '../apis/types';
|
||||
import { ChartableStage } from './types';
|
||||
import {
|
||||
@@ -57,9 +58,8 @@ import {
|
||||
colorStrokeAvg,
|
||||
} from './colors';
|
||||
|
||||
const fullWidth = {
|
||||
width: '100%',
|
||||
};
|
||||
const fullWidth: CSSProperties = { width: '100%' };
|
||||
const noUserSelect: CSSProperties = { userSelect: 'none' };
|
||||
|
||||
const transitionProps = { unmountOnExit: true };
|
||||
|
||||
@@ -81,6 +81,9 @@ export function StageChart(props: StageChartProps) {
|
||||
zeroYAxis = false,
|
||||
} = chartOptions;
|
||||
|
||||
const { zoomFilterValues } = useZoom();
|
||||
const { zoomProps, getZoomArea } = useZoomArea();
|
||||
|
||||
const ticks = useMemo(
|
||||
() => pickElements(stage.values, 8).map(val => val.__epoch),
|
||||
[stage.values],
|
||||
@@ -114,6 +117,11 @@ export function StageChart(props: StageChartProps) {
|
||||
[stage.stages, defaultHidden],
|
||||
);
|
||||
|
||||
const zoomFilteredValues = useMemo(
|
||||
() => zoomFilterValues(stage.values),
|
||||
[stage.values, zoomFilterValues],
|
||||
);
|
||||
|
||||
return stage.combinedAnalysis.max < defaultHidden ? null : (
|
||||
<Accordion
|
||||
defaultExpanded={stage.combinedAnalysis.max > defaultCollapsed}
|
||||
@@ -130,9 +138,9 @@ export function StageChart(props: StageChartProps) {
|
||||
<Alert severity="info">No data</Alert>
|
||||
) : (
|
||||
<Grid container direction="column">
|
||||
<Grid item>
|
||||
<Grid item style={noUserSelect}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart data={stage.values}>
|
||||
<ComposedChart data={zoomFilteredValues} {...zoomProps}>
|
||||
<defs>
|
||||
<linearGradient id="colorDur" x1="0" y1="0" x2="0" y2="1">
|
||||
{fireColors.map(([percent, color]) => (
|
||||
@@ -175,8 +183,9 @@ export function StageChart(props: StageChartProps) {
|
||||
{statuses.reverse().map(status => (
|
||||
<Fragment key={status}>
|
||||
{!chartTypes[status].includes('duration') ? null : (
|
||||
<Fragment key={status}>
|
||||
<>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
yAxisId={1}
|
||||
type="monotone"
|
||||
dataKey={status}
|
||||
@@ -195,6 +204,7 @@ export function StageChart(props: StageChartProps) {
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
isAnimationActive={false}
|
||||
yAxisId={1}
|
||||
type="monotone"
|
||||
dataKey={`${status} avg`}
|
||||
@@ -208,10 +218,11 @@ export function StageChart(props: StageChartProps) {
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
</Fragment>
|
||||
</>
|
||||
)}
|
||||
{!chartTypes[status].includes('count') ? null : (
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
yAxisId={2}
|
||||
type="monotone"
|
||||
dataKey={`${status} count`}
|
||||
@@ -223,6 +234,7 @@ export function StageChart(props: StageChartProps) {
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{getZoomArea({ yAxisId: 1 })}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Grid>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { useZoom, useZoomArea } from './zoom';
|
||||
import { FilterStatusType, TriggerReason } from '../apis/types';
|
||||
import { labelFormatterWithoutTime, tickFormatterX } from '../components/utils';
|
||||
import { statusColorMap, triggerColorMap } from './colors';
|
||||
@@ -49,6 +50,9 @@ export interface StatusChartProps {
|
||||
export function StatusChart(props: StatusChartProps) {
|
||||
const { analysis } = props;
|
||||
|
||||
const { zoomFilterValues } = useZoom();
|
||||
const { zoomProps, getZoomArea } = useZoomArea();
|
||||
|
||||
const values = useMemo(() => {
|
||||
return analysis.daily.values.map(value => {
|
||||
const totTriggers = analysis.daily.triggerReasons.reduce(
|
||||
@@ -122,6 +126,11 @@ export function StatusChart(props: StatusChartProps) {
|
||||
};
|
||||
}, [analysis.daily.triggerReasons]);
|
||||
|
||||
const zoomFilteredValues = useMemo(
|
||||
() => zoomFilterValues(values),
|
||||
[values, zoomFilterValues],
|
||||
);
|
||||
|
||||
const barSize = getBarSize(analysis.daily.values.length);
|
||||
|
||||
return (
|
||||
@@ -136,7 +145,7 @@ export function StatusChart(props: StatusChartProps) {
|
||||
<Alert severity="info">No data</Alert>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart data={values}>
|
||||
<ComposedChart data={zoomFilteredValues} {...zoomProps}>
|
||||
<Legend payload={legendPayload} />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
@@ -153,6 +162,7 @@ export function StatusChart(props: StatusChartProps) {
|
||||
{triggerReasonLegendPayload.map(reason => (
|
||||
<Fragment key={reason.id}>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey={reason.id!}
|
||||
stackId="triggers"
|
||||
@@ -166,6 +176,7 @@ export function StatusChart(props: StatusChartProps) {
|
||||
{[...analysis.daily.statuses].reverse().map(status => (
|
||||
<Fragment key={status}>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
barSize={barSize}
|
||||
dataKey={status}
|
||||
@@ -177,6 +188,7 @@ export function StatusChart(props: StatusChartProps) {
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{getZoomArea({ yAxisId: 1 })}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { throttle } from 'lodash';
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
Fragment,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { ReferenceArea } from 'recharts';
|
||||
|
||||
import type { Epoch } from './types';
|
||||
|
||||
interface ZoomState {
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
interface ZoomContext {
|
||||
registerSelection(setter: Dispatch<ZoomState>): void;
|
||||
setSelectState: Dispatch<SetStateAction<ZoomState>>;
|
||||
|
||||
zoomState: ZoomState;
|
||||
setZoomState: Dispatch<SetStateAction<ZoomState>>;
|
||||
|
||||
resetZoom: () => void;
|
||||
}
|
||||
|
||||
const context = React.createContext<ZoomContext>(undefined as any);
|
||||
|
||||
export function ZoomProvider({ children }: PropsWithChildren<{}>) {
|
||||
const [registeredSelectors, setRegisteredSelectors] = useState<
|
||||
Array<Dispatch<ZoomState>>
|
||||
>([]);
|
||||
const [selectState, setSelectState] = useState<ZoomState>({});
|
||||
const [zoomState, setZoomState] = useState<ZoomState>({});
|
||||
|
||||
const registerSelection = useCallback(
|
||||
(selector: Dispatch<ZoomState>) => {
|
||||
setRegisteredSelectors(old => [...old, selector]);
|
||||
|
||||
return () => {
|
||||
setRegisteredSelectors(old => old.filter(sel => sel === selector));
|
||||
};
|
||||
},
|
||||
[setRegisteredSelectors],
|
||||
);
|
||||
|
||||
const callSelectors = useCallback(
|
||||
(state: ZoomState) => {
|
||||
registeredSelectors.forEach(selector => {
|
||||
selector(state);
|
||||
});
|
||||
},
|
||||
[registeredSelectors],
|
||||
);
|
||||
|
||||
const throttledCallSelectors = useMemo(
|
||||
() => throttle(callSelectors, 200),
|
||||
[callSelectors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
throttledCallSelectors({
|
||||
left: selectState.left,
|
||||
right: selectState.right,
|
||||
});
|
||||
}, [selectState.left, selectState.right, throttledCallSelectors]);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setSelectState({});
|
||||
setZoomState({});
|
||||
}, [setSelectState, setZoomState]);
|
||||
|
||||
const value = useMemo(
|
||||
(): ZoomContext => ({
|
||||
registerSelection,
|
||||
setSelectState,
|
||||
|
||||
zoomState,
|
||||
setZoomState,
|
||||
|
||||
resetZoom,
|
||||
}),
|
||||
[registerSelection, setSelectState, zoomState, setZoomState, resetZoom],
|
||||
);
|
||||
|
||||
return <context.Provider value={value} children={children} />;
|
||||
}
|
||||
|
||||
export function useZoom() {
|
||||
const { zoomState, resetZoom } = useContext(context);
|
||||
|
||||
const zoomFilterValues = useCallback(
|
||||
<T extends Epoch>(values: Array<T>): Array<T> => {
|
||||
const { left, right } = zoomState;
|
||||
return left === undefined || right === undefined
|
||||
? values
|
||||
: values.filter(({ __epoch }) => __epoch > left && __epoch < right);
|
||||
},
|
||||
[zoomState],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
resetZoom,
|
||||
zoomState,
|
||||
zoomFilterValues,
|
||||
}),
|
||||
[resetZoom, zoomState, zoomFilterValues],
|
||||
);
|
||||
}
|
||||
|
||||
export interface ZoomAreaProps {
|
||||
yAxisId?: number | string | undefined;
|
||||
}
|
||||
|
||||
export function useZoomArea() {
|
||||
const [showSelection, setShowSelection] = useState(false);
|
||||
const [state, setState] = useState<ZoomState>({});
|
||||
const { setSelectState, setZoomState, registerSelection } =
|
||||
useContext(context);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: any) => {
|
||||
if (!e?.activeLabel) return;
|
||||
|
||||
setSelectState({ left: e.activeLabel });
|
||||
setShowSelection(true);
|
||||
},
|
||||
[setSelectState, setShowSelection],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(e: any) => {
|
||||
if (!e?.activeLabel) return;
|
||||
|
||||
setSelectState(area => {
|
||||
if (!area.left) {
|
||||
return area;
|
||||
}
|
||||
return { ...area, right: e.activeLabel };
|
||||
});
|
||||
},
|
||||
[setSelectState],
|
||||
);
|
||||
|
||||
const doZoom = useCallback(() => {
|
||||
setSelectState(old => {
|
||||
const { left, right } = old;
|
||||
|
||||
if (left === undefined || right === undefined || left === right) {
|
||||
// Either is undefined or both are same - zoom out
|
||||
setZoomState({});
|
||||
} else if (left < right) {
|
||||
setZoomState({ left, right });
|
||||
} else if (left > right) {
|
||||
setZoomState({ left: right, right: left });
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
setShowSelection(false);
|
||||
}, [setSelectState, setZoomState, setShowSelection]);
|
||||
|
||||
const zoomProps = useMemo(
|
||||
() => ({
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp: doZoom,
|
||||
}),
|
||||
[onMouseDown, onMouseMove, doZoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSelection) {
|
||||
return undefined;
|
||||
}
|
||||
return registerSelection(setState);
|
||||
}, [registerSelection, setState, showSelection]);
|
||||
|
||||
const getZoomArea = useCallback(
|
||||
(props?: ZoomAreaProps) => (
|
||||
<Fragment key="zoom-area">
|
||||
{showSelection && state.left && state.right ? (
|
||||
<ReferenceArea
|
||||
x1={state.left}
|
||||
x2={state.right}
|
||||
strokeOpacity={0.5}
|
||||
{...props}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
),
|
||||
[showSelection, state.left, state.right],
|
||||
);
|
||||
|
||||
return {
|
||||
zoomProps,
|
||||
getZoomArea,
|
||||
};
|
||||
}
|
||||
@@ -42,9 +42,17 @@ export function pickElements<T>(arr: ReadonlyArray<T>, num: number): Array<T> {
|
||||
}
|
||||
|
||||
function formatDateShort(milliseconds: number) {
|
||||
if ((milliseconds as any) === 'auto') {
|
||||
// When recharts gets confused (empty data)
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(milliseconds).toLocaleString(DateTime.DATE_SHORT);
|
||||
}
|
||||
function formatDateTimeShort(milliseconds: number) {
|
||||
if ((milliseconds as any) === 'auto') {
|
||||
// When recharts gets confused (empty data)
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(milliseconds).toLocaleString(
|
||||
DateTime.DATETIME_SHORT,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from './hooks/use-cicd-statistics';
|
||||
import { useCicdConfiguration } from './hooks/use-cicd-configuration';
|
||||
import { buildsToChartableStages } from './charts/logic/conversions';
|
||||
import { ZoomProvider, useZoom } from './charts/zoom';
|
||||
import { StageChart } from './charts/stage-chart';
|
||||
import { StatusChart } from './charts/status-chart';
|
||||
import {
|
||||
@@ -48,7 +49,9 @@ export function EntityPageCicdCharts() {
|
||||
const state = useCicdConfiguration();
|
||||
|
||||
return renderFallbacks(state, value => (
|
||||
<CicdCharts cicdConfiguration={value} />
|
||||
<ZoomProvider>
|
||||
<CicdCharts cicdConfiguration={value} />
|
||||
</ZoomProvider>
|
||||
));
|
||||
}
|
||||
|
||||
@@ -88,6 +91,8 @@ function CicdCharts(props: CicdChartsProps) {
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const { resetZoom } = useZoom();
|
||||
|
||||
const [chartFilter, setChartFilter] = useState(
|
||||
getDefaultChartFilter(cicdConfiguration),
|
||||
);
|
||||
@@ -135,6 +140,10 @@ function CicdCharts(props: CicdChartsProps) {
|
||||
[statisticsState, cicdConfiguration, viewOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetZoom();
|
||||
}, [resetZoom, statisticsState.value]);
|
||||
|
||||
const onFilterChange = useCallback((filter: ChartFilter) => {
|
||||
setChartFilter(cleanChartFilter(filter));
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user