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:
Gustaf Räntilä
2022-02-02 16:27:02 +01:00
committed by blam
parent b8454275c1
commit d5acd9f035
5 changed files with 272 additions and 10 deletions
@@ -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>
)}
+221
View File
@@ -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,
);
+10 -1
View File
@@ -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));
}, []);