[Dashboards] Graph gauge chart component (#14035)
closes https://github.com/twentyhq/core-team-issues/issues/1374 figma - https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=72808-199932&t=C0BfdTmTl69RKZvU-0 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
+238
@@ -0,0 +1,238 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { ResponsiveRadialBar } from '@nivo/radial-bar';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AppTooltip,
|
||||
H1Title,
|
||||
H1TitleFontColor,
|
||||
IconArrowUpRight,
|
||||
TooltipDelay,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
type GraphWidgetGaugeChartProps = {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
unit: string;
|
||||
showValue?: boolean;
|
||||
legendLabel: string;
|
||||
tooltipHref: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledChartContainer = styled.div`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledH1Title = styled(H1Title)`
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -150%);
|
||||
`;
|
||||
|
||||
const StyledLegendContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledLegendItem = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledLegendLabel = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
`;
|
||||
|
||||
const StyledLegendValue = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledTooltipContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTooltipRow = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.extraLight};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledDot = styled.div`
|
||||
background: ${({ theme }) => theme.color.blue};
|
||||
border-radius: 50%;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledTooltipValue = styled.span`
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledTooltipLink = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const GraphWidgetGaugeChart = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
unit,
|
||||
showValue = true,
|
||||
legendLabel,
|
||||
tooltipHref,
|
||||
id,
|
||||
}: GraphWidgetGaugeChartProps) => {
|
||||
const theme = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const formatValue = (val: number): string => {
|
||||
if (val % 1 !== 0) {
|
||||
return val.toFixed(1);
|
||||
}
|
||||
return val.toString();
|
||||
};
|
||||
|
||||
const displayValue = formatValue(value);
|
||||
|
||||
const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
|
||||
const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 'gauge',
|
||||
data: [
|
||||
{ x: 'value', y: clampedNormalizedValue },
|
||||
{ x: 'empty', y: 100 - clampedNormalizedValue },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const gradientColors = isHovered
|
||||
? {
|
||||
start: theme.adaptiveColors.blue4,
|
||||
end: theme.adaptiveColors.blue3,
|
||||
}
|
||||
: {
|
||||
start: theme.adaptiveColors.blue2,
|
||||
end: theme.adaptiveColors.blue1,
|
||||
};
|
||||
|
||||
const gradientId = `gaugeGradient-${id}`;
|
||||
|
||||
const defs = [
|
||||
{
|
||||
id: gradientId,
|
||||
type: 'linearGradient',
|
||||
colors: [
|
||||
{ offset: 0, color: gradientColors.start },
|
||||
{ offset: 100, color: gradientColors.end },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tooltipContent = (
|
||||
<StyledTooltipContent>
|
||||
<StyledTooltipRow>
|
||||
<StyledDot />
|
||||
<span>{legendLabel}</span>
|
||||
<StyledTooltipValue>{`${displayValue}${unit}`}</StyledTooltipValue>
|
||||
</StyledTooltipRow>
|
||||
<StyledTooltipLink href={tooltipHref}>
|
||||
<span>{t`Click to see data`}</span>
|
||||
<IconArrowUpRight size={theme.icon.size.sm} />
|
||||
</StyledTooltipLink>
|
||||
</StyledTooltipContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContainer
|
||||
id={id}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<StyledChartContainer>
|
||||
<ResponsiveRadialBar
|
||||
data={data}
|
||||
startAngle={-90}
|
||||
endAngle={90}
|
||||
innerRadius={0.7}
|
||||
padding={0.2}
|
||||
colors={[`url(#${gradientId})`, theme.background.tertiary]}
|
||||
defs={defs}
|
||||
fill={[
|
||||
{
|
||||
match: (d: { x: string }) => d.x === 'value',
|
||||
id: gradientId,
|
||||
},
|
||||
]}
|
||||
enableTracks={false}
|
||||
enableRadialGrid={false}
|
||||
enableCircularGrid={false}
|
||||
enableLabels={false}
|
||||
isInteractive={false}
|
||||
radialAxisStart={null}
|
||||
radialAxisEnd={null}
|
||||
circularAxisInner={null}
|
||||
circularAxisOuter={null}
|
||||
/>
|
||||
{showValue && (
|
||||
<StyledH1Title
|
||||
title={`${displayValue}${unit}`}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
)}
|
||||
</StyledChartContainer>
|
||||
<StyledLegendContainer>
|
||||
<StyledLegendItem>
|
||||
<StyledDot />
|
||||
<StyledLegendLabel>{legendLabel}</StyledLegendLabel>
|
||||
<StyledLegendValue>{`${displayValue}${unit}`}</StyledLegendValue>
|
||||
</StyledLegendItem>
|
||||
</StyledLegendContainer>
|
||||
</StyledContainer>
|
||||
<AppTooltip
|
||||
anchorSelect={`#${id}`}
|
||||
place="top-start"
|
||||
noArrow
|
||||
delay={TooltipDelay.noDelay}
|
||||
clickable
|
||||
>
|
||||
{tooltipContent}
|
||||
</AppTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
|
||||
|
||||
const meta: Meta<typeof GraphWidgetGaugeChart> = {
|
||||
title: 'Modules/Dashboards/Graphs/GraphWidgetGaugeChart',
|
||||
component: GraphWidgetGaugeChart,
|
||||
decorators: [ComponentDecorator],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
value: {
|
||||
control: { type: 'number' },
|
||||
},
|
||||
min: {
|
||||
control: { type: 'number' },
|
||||
},
|
||||
max: {
|
||||
control: { type: 'number' },
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
},
|
||||
showValue: {
|
||||
control: 'boolean',
|
||||
},
|
||||
legendLabel: {
|
||||
control: 'text',
|
||||
},
|
||||
tooltipHref: {
|
||||
control: 'text',
|
||||
},
|
||||
id: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GraphWidgetGaugeChart>;
|
||||
|
||||
const Container = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ width: '250px', height: '200px' }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
unit: '%',
|
||||
showValue: true,
|
||||
legendLabel: 'Conversion rate',
|
||||
tooltipHref: 'https://example.com',
|
||||
id: 'gauge-chart-default',
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={args.showValue}
|
||||
legendLabel={args.legendLabel}
|
||||
tooltipHref={args.tooltipHref}
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const Catalog: Story = {
|
||||
decorators: [CatalogDecorator],
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'value',
|
||||
values: [0, 25, 50, 75, 100],
|
||||
props: (value: number) => ({
|
||||
value,
|
||||
min: 0,
|
||||
max: 100,
|
||||
unit: '%',
|
||||
id: `gauge-chart-catalog-${value}`,
|
||||
}),
|
||||
labels: (value: number) => {
|
||||
const labelMap: Record<number, string> = {
|
||||
0: 'Empty',
|
||||
25: 'Quarter',
|
||||
50: 'Half',
|
||||
75: 'Three Quarters',
|
||||
100: 'Full',
|
||||
};
|
||||
return labelMap[value] ?? `${value}%`;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={true}
|
||||
legendLabel="Percentage"
|
||||
tooltipHref="https://example.com"
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithoutValue: Story = {
|
||||
args: {
|
||||
value: 65,
|
||||
min: 0,
|
||||
max: 100,
|
||||
unit: '%',
|
||||
showValue: false,
|
||||
legendLabel: 'Conversion rate',
|
||||
tooltipHref: 'https://example.com',
|
||||
id: 'gauge-chart-without-value',
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={args.showValue}
|
||||
legendLabel={args.legendLabel}
|
||||
tooltipHref={args.tooltipHref}
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const Revenue: Story = {
|
||||
args: {
|
||||
value: 750,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
unit: 'K',
|
||||
showValue: true,
|
||||
legendLabel: 'Revenue',
|
||||
tooltipHref: 'https://example.com',
|
||||
id: 'gauge-chart-revenue',
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={args.showValue}
|
||||
legendLabel={args.legendLabel}
|
||||
tooltipHref={args.tooltipHref}
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const Temperature: Story = {
|
||||
args: {
|
||||
value: 22,
|
||||
min: -10,
|
||||
max: 40,
|
||||
unit: '°C',
|
||||
showValue: true,
|
||||
legendLabel: 'Temperature',
|
||||
tooltipHref: 'https://example.com',
|
||||
id: 'gauge-chart-temperature',
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={args.showValue}
|
||||
legendLabel={args.legendLabel}
|
||||
tooltipHref={args.tooltipHref}
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const Storage: Story = {
|
||||
args: {
|
||||
value: 384,
|
||||
min: 0,
|
||||
max: 512,
|
||||
unit: 'GB',
|
||||
showValue: true,
|
||||
legendLabel: 'Storage Used',
|
||||
tooltipHref: 'https://example.com',
|
||||
id: 'gauge-chart-storage',
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={args.showValue}
|
||||
legendLabel={args.legendLabel}
|
||||
tooltipHref={args.tooltipHref}
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
|
||||
export const Rating: Story = {
|
||||
args: {
|
||||
value: 4.2,
|
||||
min: 0,
|
||||
max: 5,
|
||||
unit: ' ⭐',
|
||||
showValue: true,
|
||||
legendLabel: 'Average Rating',
|
||||
tooltipHref: 'https://example.com',
|
||||
id: 'gauge-chart-rating',
|
||||
},
|
||||
render: (args) => (
|
||||
<Container>
|
||||
<GraphWidgetGaugeChart
|
||||
value={args.value}
|
||||
min={args.min}
|
||||
max={args.max}
|
||||
unit={args.unit}
|
||||
showValue={args.showValue}
|
||||
legendLabel={args.legendLabel}
|
||||
tooltipHref={args.tooltipHref}
|
||||
id={args.id}
|
||||
/>
|
||||
</Container>
|
||||
),
|
||||
};
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
import styled from '@emotion/styled';
|
||||
import type { Point } from '@nivo/line';
|
||||
import type { Point, LineSeries } from '@nivo/line';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
const StyledTooltipContainer = styled.div`
|
||||
@@ -45,7 +45,7 @@ const StyledTooltipValue = styled.span`
|
||||
|
||||
type WorkerMetricsTooltipProps = {
|
||||
slice: {
|
||||
points: readonly Point[];
|
||||
points: readonly Point<LineSeries>[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,11 +55,11 @@ export const WorkerMetricsTooltip = ({
|
||||
return (
|
||||
<StyledTooltipContainer>
|
||||
{slice.points.map((point) => (
|
||||
<StyledTooltipItem key={point.id} color={point.serieColor}>
|
||||
<StyledTooltipColorCircle color={point.serieColor} />
|
||||
<StyledTooltipItem key={point.id} color={point.seriesColor}>
|
||||
<StyledTooltipColorCircle color={point.seriesColor} />
|
||||
<StyledTooltipDataRow>
|
||||
<span>{point.serieId}</span>
|
||||
<StyledTooltipValue>{String(point.data.y)}</StyledTooltipValue>
|
||||
<span>{point.seriesId}</span>
|
||||
<StyledTooltipValue>{point.data.yFormatted}</StyledTooltipValue>
|
||||
</StyledTooltipDataRow>
|
||||
</StyledTooltipItem>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user