mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-04 01:18:20 +02: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 { FlagsProjectChart } from './FlagsProjectChart/FlagsProjectChart';
|
||||||
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
|
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
|
||||||
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
|
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
|
||||||
|
import { TimeToProduction } from './TimeToProduction/TimeToProduction';
|
||||||
|
|
||||||
const StyledGrid = styled(Box)(({ theme }) => ({
|
const StyledGrid = styled(Box)(({ theme }) => ({
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -141,11 +142,11 @@ export const ExecutiveDashboard: VFC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Widget>
|
</Widget>
|
||||||
<Widget
|
<Widget title='Average time to production' order={7}>
|
||||||
title='Time to production'
|
{/* FIXME: data from API */}
|
||||||
order={7}
|
<TimeToProduction daysToProduction={12} />
|
||||||
span={largeChartSpan}
|
</Widget>
|
||||||
>
|
<Widget title='Time to production' order={8} span={chartSpan}>
|
||||||
<TimeToProductionChart
|
<TimeToProductionChart
|
||||||
projectFlagTrends={
|
projectFlagTrends={
|
||||||
executiveDashboardData.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,
|
width: 220,
|
||||||
padding: theme.spacing(1.5, 2),
|
padding: theme.spacing(1.5, 2),
|
||||||
pointerEvents: 'none',
|
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,
|
borderColor: color,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
fill: true,
|
fill: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -278,6 +278,19 @@ const theme = {
|
|||||||
// A700: '#A6000E',
|
// A700: '#A6000E',
|
||||||
},
|
},
|
||||||
variants: colors.variants,
|
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',
|
// A700: '#A6000E',
|
||||||
},
|
},
|
||||||
variants: colors.variants,
|
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, percentage split in strategies
|
||||||
**/
|
**/
|
||||||
variants: string[];
|
variants: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard and charts
|
||||||
|
*/
|
||||||
|
charts: {
|
||||||
|
gauge: {
|
||||||
|
gradientStart: string;
|
||||||
|
gradientEnd: string;
|
||||||
|
background: string;
|
||||||
|
sectionLine: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
interface Theme extends CustomTheme {}
|
interface Theme extends CustomTheme {}
|
||||||
interface ThemeOptions extends CustomTheme {}
|
interface ThemeOptions extends CustomTheme {}
|
||||||
|
Loading…
Reference in New Issue
Block a user