From d5acd9f03554d19c5d15a84e233c976fabe481b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Wed, 2 Feb 2022 16:27:02 +0100 Subject: [PATCH] feat(cicd-statistics): Added zoom capability to the charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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ä --- .../src/charts/stage-chart.tsx | 28 ++- .../src/charts/status-chart.tsx | 14 +- plugins/cicd-statistics/src/charts/zoom.tsx | 221 ++++++++++++++++++ .../cicd-statistics/src/components/utils.tsx | 8 + plugins/cicd-statistics/src/entity-page.tsx | 11 +- 5 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 plugins/cicd-statistics/src/charts/zoom.tsx diff --git a/plugins/cicd-statistics/src/charts/stage-chart.tsx b/plugins/cicd-statistics/src/charts/stage-chart.tsx index 4141a6755a..ba3474153c 100644 --- a/plugins/cicd-statistics/src/charts/stage-chart.tsx +++ b/plugins/cicd-statistics/src/charts/stage-chart.tsx @@ -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 : ( defaultCollapsed} @@ -130,9 +138,9 @@ export function StageChart(props: StageChartProps) { No data ) : ( - + - + {fireColors.map(([percent, color]) => ( @@ -175,8 +183,9 @@ export function StageChart(props: StageChartProps) { {statuses.reverse().map(status => ( {!chartTypes[status].includes('duration') ? null : ( - + <> - + )} {!chartTypes[status].includes('count') ? null : ( ))} + {getZoomArea({ yAxisId: 1 })} diff --git a/plugins/cicd-statistics/src/charts/status-chart.tsx b/plugins/cicd-statistics/src/charts/status-chart.tsx index 5a4cc953ca..7b07885ca5 100644 --- a/plugins/cicd-statistics/src/charts/status-chart.tsx +++ b/plugins/cicd-statistics/src/charts/status-chart.tsx @@ -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) { No data ) : ( - + ( ( ))} + {getZoomArea({ yAxisId: 1 })} )} diff --git a/plugins/cicd-statistics/src/charts/zoom.tsx b/plugins/cicd-statistics/src/charts/zoom.tsx new file mode 100644 index 0000000000..57e2b8678f --- /dev/null +++ b/plugins/cicd-statistics/src/charts/zoom.tsx @@ -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): void; + setSelectState: Dispatch>; + + zoomState: ZoomState; + setZoomState: Dispatch>; + + resetZoom: () => void; +} + +const context = React.createContext(undefined as any); + +export function ZoomProvider({ children }: PropsWithChildren<{}>) { + const [registeredSelectors, setRegisteredSelectors] = useState< + Array> + >([]); + const [selectState, setSelectState] = useState({}); + const [zoomState, setZoomState] = useState({}); + + const registerSelection = useCallback( + (selector: Dispatch) => { + 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 ; +} + +export function useZoom() { + const { zoomState, resetZoom } = useContext(context); + + const zoomFilterValues = useCallback( + (values: Array): Array => { + 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({}); + 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) => ( + + {showSelection && state.left && state.right ? ( + + ) : null} + + ), + [showSelection, state.left, state.right], + ); + + return { + zoomProps, + getZoomArea, + }; +} diff --git a/plugins/cicd-statistics/src/components/utils.tsx b/plugins/cicd-statistics/src/components/utils.tsx index 2b6d1ad029..e4663c8cde 100644 --- a/plugins/cicd-statistics/src/components/utils.tsx +++ b/plugins/cicd-statistics/src/components/utils.tsx @@ -42,9 +42,17 @@ export function pickElements(arr: ReadonlyArray, num: number): Array { } 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, ); diff --git a/plugins/cicd-statistics/src/entity-page.tsx b/plugins/cicd-statistics/src/entity-page.tsx index 532900c1da..74a0add0b3 100644 --- a/plugins/cicd-statistics/src/entity-page.tsx +++ b/plugins/cicd-statistics/src/entity-page.tsx @@ -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 => ( - + + + )); } @@ -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)); }, []);