Skip to content

Commit

Permalink
feat: add series percentage chart (#103)
Browse files Browse the repository at this point in the history
* feat: add series percentage chart

* chore: export chart
  • Loading branch information
iagormoraes authored Jan 17, 2025
1 parent c69e10c commit fbd3fb9
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 0 deletions.
179 changes: 179 additions & 0 deletions src/components/chart/SeriesPercentageChart/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React, { type ReactElement, useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import Decimal from 'decimal.js';
import { Box, type SxProps } from '@mui/material';

import { formatDateMMYY, formatExtendedDate } from '../../../utils/date';

interface KeyValue {
key: string;
name: string;
color: string;
}

interface ChartDataPoint {
date: number;
[key: string]: number; // Allow dynamic key access
}

interface SeriesChartData {
uuid: string;
chartData: ChartDataPoint[];
}

interface SeriesPercentageChartProps {
data: SeriesChartData[];
filter: { timezone: string };
keyValues: KeyValue[];
sx?: SxProps;
}

interface FormattedChartData {
date: string;
total: number;
oneClickSuccess: number;
oneClickCreated: number;
oneClickCancelled: number;
[key: string]: string | number; // Allow dynamic key access
}

const formatChartData = (
data: SeriesChartData[],
keyValues: KeyValue[],
): FormattedChartData[] => {
// Create a map to store all unique dates
const dateMap = new Map<number, FormattedChartData>();

// Collect all unique dates from all series
data.forEach((series) => {
series.chartData.forEach((point) => {
if (!dateMap.has(point.date)) {
const entry: FormattedChartData = {
date: point.date.toString(),
total: 0,
oneClickSuccess: 0,
oneClickCreated: 0,
oneClickCancelled: 0,
};
// Initialize all keyValues with 0
keyValues.forEach(({ key }) => {
entry[key] = 0;
});
dateMap.set(point.date, entry);
}

// Add the values for each key
keyValues.forEach(({ key }) => {
const currentEntry = dateMap.get(
point.date,
) as unknown as FormattedChartData;
currentEntry[key] = point[key] || 0;
});
});
});

// Convert the map to array and add totals
return Array.from(dateMap.values())
.map((entry) => {
const total = keyValues.reduce(
(sum, { key }) => sum + ((entry[key] as number) || 0),
0,
);
return {
...entry,
total,
};
})
.sort((a, b) => parseInt(a.date) - parseInt(b.date));
};

export function SeriesPercentageChart(
props: SeriesPercentageChartProps,
): ReactElement {
const formattedData = useMemo(() => {
if (!props.data) return [];
return formatChartData(props.data, props.keyValues);
}, [props.data, props.keyValues]);

return (
<Box sx={{ width: '100%', height: '100%', ...props.sx }}>
<ResponsiveContainer width='100%' height='100%'>
<AreaChart
data={formattedData}
stackOffset='expand'
width={500}
height={300}
margin={{
top: 5,
right: 60,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' />
<XAxis
dataKey='date'
type='number'
domain={['dataMin', 'dataMax']}
tickFormatter={(value) =>
formatDateMMYY(value, {
timeZone: props.filter.timezone,
hour12: false,
hour: 'numeric',
})
}
allowDuplicatedCategory={false}
tickLine={false}
fontSize={12}
tickMargin={12}
/>
<YAxis
textAnchor='end'
tickLine={false}
tickFormatter={(value) => `${(value * 100).toFixed(2)}%`}
/>
<Tooltip
formatter={(value, name, item) => {
const total = item.payload.total;
const percentage =
total === 0
? '0.00'
: new Decimal(value.toString())
.div(total)
.mul(100)
.toFixed(2, Decimal.ROUND_DOWN);

return [`${value.toString()} (${percentage}%)`, name];
}}
labelFormatter={(value) =>
formatExtendedDate(value, {
timeZone: props.filter.timezone,
hour12: false,
})
}
/>
{props.keyValues.map((keyValue) => (
<Area
key={keyValue.key}
type='monotone'
dataKey={keyValue.key}
name={keyValue.name}
stackId='1'
stroke={keyValue.color}
fill={keyValue.color}
strokeWidth={2}
/>
))}
</AreaChart>
</ResponsiveContainer>
</Box>
);
}
1 change: 1 addition & 0 deletions src/components/chart/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './SeriesChart';
export * from './SeriesChartLegend';
export * from './SeriesPercentageChart';
82 changes: 82 additions & 0 deletions src/stories/components/chart/SeriesPercentageChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Meta, StoryObj } from '@storybook/react';
import { SeriesPercentageChart } from '../../../components/chart/SeriesPercentageChart';

const meta = {
title: 'components/chart/SeriesPercentageChart',
component: SeriesPercentageChart,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof SeriesPercentageChart>;

export default meta;
type Story = StoryObj<typeof meta>;

const generateRandomData = (days = 30) => {
const data: any = [];
const now = new Date().getTime(); // Using provided current time

for (let i = 0; i < days; i++) {
// Generate timestamp for each day, starting from the most recent
const date = now - i * 24 * 60 * 60 * 1000;

// Generate random values between 0-5 for each series
const oneClickCreated = Math.floor(Math.random() * 6);
const oneClickSuccess = Math.floor(Math.random() * (oneClickCreated + 1)); // Success should be <= Created
const oneClickCancelled = Math.floor(
Math.random() * (oneClickCreated - oneClickSuccess + 1),
); // Cancelled should be <= (Created - Success)

data.push({
date,
oneClickCreated,
oneClickSuccess,
oneClickCancelled,
});
}

// Sort by date ascending
return data.sort((a, b) => a.date - b.date);
};

const sampleData = [
{
uuid: 'b07cbc37-fe8f-4920-a6b9-c4e9dfe193cd',
chartData: generateRandomData(30),
},
];

const keyValues = [
{ key: 'oneClickSuccess', name: 'Success', color: '#0DBC3D' },
{ key: 'oneClickCreated', name: 'Created', color: '#fbbc05' },
{ key: 'oneClickCancelled', name: 'Cancelled', color: '#808080' },
];

export const Default: Story = {
args: {
data: sampleData,
filter: {
timezone: 'UTC',
},
keyValues: keyValues,
sx: {
width: 800,
height: 400,
},
},
};

export const Empty: Story = {
args: {
data: [],
filter: {
timezone: 'UTC',
},
keyValues: keyValues,
sx: {
width: 800,
height: 400,
},
},
};
3 changes: 3 additions & 0 deletions vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export default defineConfig({
'src/components/animation/index.ts',
),
'components/chart': resolvePath('src/components/chart/index.ts'),
'components/typographies': resolvePath(
'src/components/typographies/index.ts',
),
hooks: resolvePath('src/hooks/index.ts'),
styles: resolvePath('src/styles/index.ts'),
validations: resolvePath('src/validations/index.ts'),
Expand Down

0 comments on commit fbd3fb9

Please sign in to comment.