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:
parent
624524819a
commit
0d51bad67b
@ -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
|
||||
|
195
frontend/src/component/executiveDashboard/Gauge/Gauge.tsx
Normal file
195
frontend/src/component/executiveDashboard/Gauge/Gauge.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -52,6 +52,7 @@ export const ChartTooltip: VFC<IChartTooltipProps> = ({ tooltip }) => (
|
||||
width: 220,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
pointerEvents: 'none',
|
||||
zIndex: theme.zIndex.tooltip,
|
||||
})}
|
||||
>
|
||||
{
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -35,7 +35,7 @@ export const useProjectChartData = (
|
||||
}),
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
fill: true,
|
||||
fill: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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 {}
|
||||
|
Loading…
Reference in New Issue
Block a user