1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: dashboard lead time gauge (#6225)

This commit is contained in:
Tymoteusz Czech 2024-02-14 13:32:12 +01:00 committed by GitHub
parent 624524819a
commit 0d51bad67b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 348 additions and 6 deletions

View File

@ -16,6 +16,7 @@ import { Widget } from './Widget/Widget';
import { FlagsProjectChart } from './FlagsProjectChart/FlagsProjectChart';
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
import { TimeToProduction } from './TimeToProduction/TimeToProduction';
const StyledGrid = styled(Box)(({ theme }) => ({
display: 'grid',
@ -141,11 +142,11 @@ export const ExecutiveDashboard: VFC = () => {
}
/>
</Widget>
<Widget
title='Time to production'
order={7}
span={largeChartSpan}
>
<Widget title='Average time to production' order={7}>
{/* FIXME: data from API */}
<TimeToProduction daysToProduction={12} />
</Widget>
<Widget title='Time to production' order={8} span={chartSpan}>
<TimeToProductionChart
projectFlagTrends={
executiveDashboardData.projectFlagTrends

View File

@ -0,0 +1,195 @@
import { VFC } from 'react';
import { Box, useTheme } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const polarToCartesian = (
centerX: number,
centerY: number,
radius: number,
angleInRadians: number,
) => {
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const degreesToRadians = (degrees: number) => degrees * (Math.PI / 180);
const normalizeValue = (value: number, min: number, max: number) =>
(value - min) / (max - min);
const describeArc = (radius: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(0, 0, radius, endAngle);
const end = polarToCartesian(0, 0, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= Math.PI ? '0' : '1';
const d = [
'M',
start.x,
start.y,
'A',
radius,
radius,
0,
largeArcFlag,
0,
end.x,
end.y,
].join(' ');
return d;
};
const GaugeLines = () => {
const theme = useTheme();
const startRadius = 0.875;
const endRadius = 1.14;
const lineWidth = 0.1;
const lineBorder = 0.05;
const angles = [180 + 50, 180 + 90 + 40];
return (
<>
{angles.map(degreesToRadians).map((angle) => {
const start = polarToCartesian(0, 0, startRadius, angle);
const end = polarToCartesian(0, 0, endRadius, angle);
return (
<>
<path
key={angle}
d={`M ${start.x} ${start.y} L ${end.x} ${end.y}`}
fill='none'
stroke={theme.palette.background.paper}
strokeWidth={lineWidth}
strokeLinecap='round'
/>
<path
key={angle}
d={`M ${start.x} ${start.y} L ${end.x} ${end.y}`}
fill='none'
stroke={theme.palette.charts.gauge.sectionLine}
strokeWidth={lineWidth - lineBorder}
strokeLinecap='round'
/>
)
</>
);
})}
</>
);
};
const GaugeText = () => {
const fontSize = 0.175;
return (
<>
<text
x={0}
y={0}
transform='translate(-1.15 -0.5) rotate(-65)'
textAnchor='middle'
fontSize={fontSize}
fill='currentColor'
>
Slow
</text>
<text
textAnchor='middle'
transform='translate(0 -1.25)'
fontSize={fontSize}
fill='currentColor'
>
Medium
</text>
<text
transform='translate(1.15 -0.5) rotate(65)'
textAnchor='middle'
fontSize={fontSize}
fill='currentColor'
>
Fast
</text>
</>
);
};
type GaugeProps = {
value?: number;
min?: number;
max?: number;
};
export const Gauge: VFC<GaugeProps> = ({ value, min = 0, max = 100 }) => {
const theme = useTheme();
const radius = 1;
const lineWidth = 0.25;
const startAngle = -Math.PI;
const endAngle = 0;
// Calculate the filled arc proportion based on the value
const valueAngle =
startAngle +
(endAngle - startAngle) * normalizeValue(value || 0, min, max);
const backgroundArcPath = describeArc(radius, startAngle, endAngle);
const filledArcPath = describeArc(radius, startAngle, valueAngle);
return (
<Box
style={{ textAlign: 'center', fontFamily: 'system-ui, sans-serif' }}
sx={{ color: theme.palette.charts.gauge.text }}
>
<svg
width='250'
height='150'
viewBox={[-1.5, -1.5, 3, 1.5].join(' ')}
style={{ overflow: 'visible' }}
>
<title>Gauge chart for time to production</title>
<defs>
<linearGradient
id='Gauge__gradient'
gradientUnits='userSpaceOnUse'
x1='-1'
x2='1'
y1='0'
y2='0'
gradientTransform='rotate(-45)'
>
<stop
offset='0%'
stopColor={theme.palette.charts.gauge.gradientStart}
/>
<stop
offset='100%'
stopColor={theme.palette.charts.gauge.gradientEnd}
/>
</linearGradient>
</defs>
<path
d={backgroundArcPath}
fill='none'
stroke={theme.palette.charts.gauge.background}
strokeWidth={lineWidth - 0.01}
strokeLinecap='round'
/>
<ConditionallyRender
condition={value !== undefined}
show={
<path
d={filledArcPath}
fill='none'
stroke='url(#Gauge__gradient)'
strokeWidth={lineWidth}
strokeLinecap='round'
/>
}
/>
<GaugeLines />
<GaugeText />
</svg>
</Box>
);
};

View File

@ -52,6 +52,7 @@ export const ChartTooltip: VFC<IChartTooltipProps> = ({ tooltip }) => (
width: 220,
padding: theme.spacing(1.5, 2),
pointerEvents: 'none',
zIndex: theme.zIndex.tooltip,
})}
>
{

View File

@ -0,0 +1,106 @@
import { VFC } from 'react';
import { Typography, styled } from '@mui/material';
import { Gauge } from '../Gauge/Gauge';
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing(1),
textAlign: 'center',
}));
const StyledText = styled('div')(({ theme }) => ({
marginTop: theme.spacing(-7),
display: 'flex',
flexDirection: 'column',
}));
type TimeToProductionProps = {
daysToProduction: number | undefined;
};
const interpolate = (
value: number,
[fromStart, fromEnd]: [number, number],
[toStart, toEnd]: [number, number],
): number => {
if (value < fromStart) {
return toStart;
}
if (value > fromEnd) {
return toEnd;
}
return (
((value - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) +
toStart
);
};
const resolveValue = (
daysToProduction: number | undefined,
): {
value: string | undefined;
gauge: number | undefined;
score: 'Fast' | 'Medium' | 'Slow' | undefined;
} => {
if (daysToProduction === undefined) {
return {
value: undefined,
gauge: undefined,
score: undefined,
};
}
if (daysToProduction <= 7) {
return {
value: `${daysToProduction.toFixed(1)} days`,
gauge: interpolate(daysToProduction, [1, 7], [100, 75]),
score: 'Fast',
};
}
if (daysToProduction <= 31) {
return {
value: `${(daysToProduction / 7).toFixed(1)} weeks`,
gauge: interpolate(daysToProduction, [7, 31], [67.5, 30]),
score: 'Medium',
};
}
return {
value: `${(daysToProduction / 30).toFixed(1)} months`,
gauge: interpolate(daysToProduction, [31, 365 / 4], [23, 0]),
score: 'Slow',
};
};
export const TimeToProduction: VFC<TimeToProductionProps> = ({
daysToProduction,
}) => {
const { value, gauge, score } = resolveValue(daysToProduction);
return (
<StyledContainer>
<Gauge value={gauge} />
<StyledText>
<Typography variant='h2' component='div'>
{daysToProduction !== undefined ? value : 'N/A'}
</Typography>
<Typography
variant='body2'
sx={(theme) => ({
color: score
? theme.palette.primary.main
: theme.palette.text.secondary,
})}
>
{score || 'No data'}
</Typography>
</StyledText>
</StyledContainer>
);
};

View File

@ -35,7 +35,7 @@ export const useProjectChartData = (
}),
borderColor: color,
backgroundColor: color,
fill: true,
fill: false,
};
},
);

View File

@ -278,6 +278,19 @@ const theme = {
// A700: '#A6000E',
},
variants: colors.variants,
/**
* Dashboard and charts
*/
charts: {
gauge: {
gradientStart: '#4C4992',
gradientEnd: '#9792ED',
background: '#39384C',
sectionLine: '#8c89bf',
text: colors.grey[800],
},
},
},
};

View File

@ -263,6 +263,19 @@ export const theme = {
// A700: '#A6000E',
},
variants: colors.variants,
/**
* Dashboard and charts
*/
charts: {
gauge: {
gradientStart: colors.purple[100],
gradientEnd: colors.purple[700],
background: colors.purple[50],
sectionLine: colors.purple[500],
text: colors.grey[600],
},
},
},
};

View File

@ -121,6 +121,19 @@ declare module '@mui/material/styles' {
* Variants, percentage split in strategies
**/
variants: string[];
/**
* Dashboard and charts
*/
charts: {
gauge: {
gradientStart: string;
gradientEnd: string;
background: string;
sectionLine: string;
text: string;
};
};
}
interface Theme extends CustomTheme {}
interface ThemeOptions extends CustomTheme {}