diff --git a/packages/saas-ui-charts/package.json b/packages/saas-ui-charts/package.json index 38bb8a422..43e77447d 100644 --- a/packages/saas-ui-charts/package.json +++ b/packages/saas-ui-charts/package.json @@ -38,6 +38,7 @@ "react-dom": ">=18" }, "devDependencies": { + "date-fns": "^3.1.0", "prop-types": "^15.8.1", "tsup": "^6.7.0" } diff --git a/packages/saas-ui-charts/src/area-chart.tsx b/packages/saas-ui-charts/src/area-chart.tsx index 5d78f3d3b..c3be932e1 100644 --- a/packages/saas-ui-charts/src/area-chart.tsx +++ b/packages/saas-ui-charts/src/area-chart.tsx @@ -1,5 +1,6 @@ import * as React from 'react' +import { ClassNames } from '@emotion/react' import { Box, SystemProps, @@ -18,23 +19,39 @@ import { Tooltip, ResponsiveContainer, TooltipProps, + Legend, } from 'recharts' +import type { CurveType } from 'recharts/types/shape/Curve' -import { ClassNames } from '@emotion/react' -import { ChartData } from './types' +import { ChartLegend } from './legend' export interface AreaChartProps { - data: ChartData[] + allowDecimals?: boolean + animationDuration?: number + data: Record[] + categories?: string[] + colors?: string[] + index?: string + intervalType?: 'preserveStartEnd' | 'equidistantPreserveStart' height: SystemProps['height'] - showGrid?: boolean - color?: string + connectNulls?: boolean + curveType?: CurveType strokeWidth?: string name?: string gradientOpacity?: number - tickFormatter?(value: number): string - variant?: 'line' | 'solid' | 'gradient' + valueFormatter?(value: number): string + showAnimation?: boolean + showGrid?: boolean + showLegend?: boolean + showXAxis?: boolean + showYAxis?: boolean + stack?: boolean + startEndOnly?: boolean tooltipContent?(props: TooltipProps): React.ReactNode tooltipFormatter?(value: string, name: string, props: any): string + variant?: 'line' | 'solid' | 'gradient' + yAxisWidth?: number + legendHeight?: number children?: React.ReactNode } @@ -45,8 +62,6 @@ export const AreaChart = React.forwardRef( categories = [], colors = ['primary'], height, - showGrid = true, - color = 'primary', connectNulls = false, curveType = 'linear', index = 'date', @@ -54,23 +69,28 @@ export const AreaChart = React.forwardRef( intervalType = 'equidistantPreserveStart', allowDecimals = true, strokeWidth = 2, + showAnimation = true, + showGrid = true, + showLegend = true, + showXAxis = true, + showYAxis = true, stack = false, - yAxisWidth = 30, + yAxisWidth = 40, + legendHeight = 32, animationDuration = 500, name, - tickFormatter, + valueFormatter, variant = 'gradient', gradientOpacity = 0.8, tooltipContent, - tooltipFormatter = (value: string, name: string, props: any) => { - return props.payload.yv - }, children, } = props const theme = useTheme() const id = useId() - const styles = useStyleConfig('Tooltip') + + const tooltipTheme = useStyleConfig('Tooltip') + const tooltipStyles = css(tooltipTheme)(theme) const categoryColors = Object.fromEntries( categories.map((category, index) => [category, colors[index] || 'gray']) @@ -80,19 +100,21 @@ export const AreaChart = React.forwardRef( return theme.colors[categoryColors[category]]?.[500] } - const getFill = (category: strong) => { + const getGradientId = (category: string) => { + return `${id}-${categoryColors[category]}-gradient` + } + + const getFill = (category: string) => { switch (variant) { case 'solid': return getColor(category) case 'gradient': - return `url(#${categoryColors[category]}-gradient)` + return `url(#${getGradientId(category)})` default: return 'transparent' } } - const tooltipStyles = css(styles)(theme) - return ( {({ css }) => { @@ -100,28 +122,6 @@ export const AreaChart = React.forwardRef( - - {categories.map((category, index) => ( - - - - - ))} - {showGrid && ( ( strokeOpacity={useColorModeValue(0.8, 0.3)} /> )} + ( tickLine={false} axisLine={false} minTickGap={5} + style={{ + color: 'var(--chakra-colors-muted)', + }} /> ( content={tooltipContent} /> + {showLegend && ( + { + return + }} + /> + )} + + + {categories.map((category) => ( + + + + + ))} + + {children} {categories.map((category) => ( @@ -180,7 +226,7 @@ export const AreaChart = React.forwardRef( strokeLinecap="round" fill={getFill(category)} name={name} - // isAnimationActive={showAnimation} + isAnimationActive={showAnimation} animationDuration={animationDuration} stackId={stack ? 'a' : undefined} connectNulls={connectNulls} diff --git a/packages/saas-ui-charts/src/index.ts b/packages/saas-ui-charts/src/index.ts index 48fcae8b4..7d39c9768 100644 --- a/packages/saas-ui-charts/src/index.ts +++ b/packages/saas-ui-charts/src/index.ts @@ -1,5 +1,5 @@ export { type SparklineProps, Sparklines } from './sparklines' -export { LineChart, type LineChartProps } from './line-chart' export { AreaChart, type AreaChartProps } from './area-chart' +export { LineChart, type LineChartProps } from './line-chart' export { BarChart, type BarChartProps } from './bar-chart' export type { ChartData } from './types' diff --git a/packages/saas-ui-charts/src/legend.tsx b/packages/saas-ui-charts/src/legend.tsx new file mode 100644 index 000000000..e81199f58 --- /dev/null +++ b/packages/saas-ui-charts/src/legend.tsx @@ -0,0 +1,21 @@ +import { Box, HStack, forwardRef } from '@chakra-ui/react' +import { Payload } from 'recharts/types/component/DefaultLegendContent' + +export interface ChartLegendProps { + payload?: Payload[] +} + +export const ChartLegend = forwardRef( + ({ payload }, ref) => { + return ( + + {payload?.map((entry, index) => ( + + + {entry.value} + + ))} + + ) + } +) diff --git a/packages/saas-ui-charts/src/line-chart.tsx b/packages/saas-ui-charts/src/line-chart.tsx index c831ce1f2..14b00b435 100644 --- a/packages/saas-ui-charts/src/line-chart.tsx +++ b/packages/saas-ui-charts/src/line-chart.tsx @@ -1,5 +1,6 @@ import * as React from 'react' +import { ClassNames } from '@emotion/react' import { Box, SystemProps, @@ -10,99 +11,103 @@ import { } from '@chakra-ui/react' import { css } from '@chakra-ui/styled-system' import { - AreaChart, - Area, + LineChart as ReLineChart, + Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, TooltipProps, + Legend, } from 'recharts' +import type { CurveType } from 'recharts/types/shape/Curve' -import { ClassNames } from '@emotion/react' -import { ChartData } from './types' +import { ChartLegend } from './legend' export interface LineChartProps { - data: ChartData[] + allowDecimals?: boolean + animationDuration?: number + data: Record[] + categories?: string[] + colors?: string[] + index?: string + intervalType?: 'preserveStartEnd' | 'equidistantPreserveStart' height: SystemProps['height'] - showGrid?: boolean - color?: string + connectNulls?: boolean + curveType?: CurveType strokeWidth?: string name?: string - gradientOpacity?: number tickFormatter?(value: number): string - variant?: 'line' | 'solid' | 'gradient' + showAnimation?: boolean + showGrid?: boolean + showLegend?: boolean + showXAxis?: boolean + showYAxis?: boolean + stack?: boolean + startEndOnly?: boolean tooltipContent?(props: TooltipProps): React.ReactNode tooltipFormatter?(value: string, name: string, props: any): string + variant?: 'line' | 'solid' | 'gradient' + yAxisWidth?: number + legendHeight?: number children?: React.ReactNode } -export const LineChart = React.forwardRef( +export const LineChart = React.forwardRef( (props, ref) => { const { - data, + data = [], + categories = [], + colors = ['primary'], height, + connectNulls = false, + curveType = 'linear', + index = 'date', + startEndOnly = false, + intervalType = 'equidistantPreserveStart', + allowDecimals = true, + strokeWidth = 2, + showAnimation = true, showGrid = true, - color = 'primary', - strokeWidth, + showLegend = true, + showXAxis = true, + showYAxis = true, + stack = false, + yAxisWidth = 40, + legendHeight = 32, + animationDuration = 500, name, tickFormatter, variant, - gradientOpacity = 0.8, tooltipContent, tooltipFormatter = (value: string, name: string, props: any) => { - return props.payload.yv + return value }, children, } = props const theme = useTheme() const id = useId() - const styles = useStyleConfig('Tooltip') - const strokeColor = theme.colors[color]?.[500] + const tooltipTheme = useStyleConfig('Tooltip') + const tooltipStyles = css(tooltipTheme)(theme) - const fill = (() => { - switch (variant) { - case 'solid': - return strokeColor - case 'gradient': - return `url(#${id}-gradient)` - default: - return 'transparent' - } - })() + const categoryColors = Object.fromEntries( + categories.map((category, index) => [category, colors[index] || 'gray']) + ) - const tooltipStyles = css(styles)(theme) + const getColor = (category: string) => { + return theme.colors[categoryColors[category]]?.[500] + } return ( {({ css }) => { return ( - + - - - - - - - + {showGrid && ( ( strokeOpacity={useColorModeValue(0.8, 0.3)} /> )} - - + + + + ( content={tooltipContent} /> + {showLegend && ( + { + return + }} + /> + )} + {children} - - + {categories.map((category) => ( + + ))} + ) diff --git a/packages/saas-ui-charts/stories/area-chart.stories.tsx b/packages/saas-ui-charts/stories/area-chart.stories.tsx index aa977ce1f..c933786cf 100644 --- a/packages/saas-ui-charts/stories/area-chart.stories.tsx +++ b/packages/saas-ui-charts/stories/area-chart.stories.tsx @@ -1,8 +1,14 @@ -import { Container, Text } from '@chakra-ui/react' +import { + Card, + CardBody, + CardHeader, + Container, + Heading, +} from '@chakra-ui/react' import * as React from 'react' import { StoryObj } from '@storybook/react' - import { AreaChart } from '../src' +import { createData } from './utils' export default { title: 'Components/Visualization/AreaChart', @@ -20,39 +26,64 @@ type Story = StoryObj export const Basic: Story = { args: { - data: [ - { - date: 'Jan 22', - SemiAnalysis: 2890, - 'The Pragmatic Engineer': 2338, - }, - { - date: 'Feb 22', - SemiAnalysis: 2756, - 'The Pragmatic Engineer': 2103, - }, - { - date: 'Mar 22', - SemiAnalysis: 3322, - 'The Pragmatic Engineer': 2194, - }, - { - date: 'Apr 22', - SemiAnalysis: 3470, - 'The Pragmatic Engineer': 2108, - }, - { - date: 'May 22', - SemiAnalysis: 3475, - 'The Pragmatic Engineer': 1812, - }, - { - date: 'Jun 22', - SemiAnalysis: 3129, - 'The Pragmatic Engineer': 1726, - }, - ], + data: createData({ + startDate: '2023-01-01', + endDate: '2023-06-30', + categories: ['Revenue'], + growthRate: 1.005, + interval: 7, + }), + height: '300px', + categories: ['Revenue'], + yAxisWidth: 80, + valueFormatter: (value) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(value) + }, + }, + render: (args) => { + return ( + + + + Revenue growth + + + + + + + ) + }, +} + +export const Multiple: Story = { + args: { + data: createData({ + startDate: '2023-12-01', + endDate: '2023-12-31', + categories: ['Backend', 'Frontend'], + startValues: [50, 30], + growthRate: 1.01, + }), height: '300px', - categories: ['SemiAnalysis', 'The Pragmatic Engineer'], + categories: ['Backend', 'Frontend'], + colors: ['purple', 'cyan'], + }, + render: (args) => { + return ( + + + + Developers + + + + + + + ) }, } diff --git a/packages/saas-ui-charts/stories/line-chart.stories.tsx b/packages/saas-ui-charts/stories/line-chart.stories.tsx new file mode 100644 index 000000000..79c4061a4 --- /dev/null +++ b/packages/saas-ui-charts/stories/line-chart.stories.tsx @@ -0,0 +1,89 @@ +import { + Card, + CardBody, + CardHeader, + Container, + Heading, +} from '@chakra-ui/react' +import * as React from 'react' +import { StoryObj } from '@storybook/react' +import { LineChart } from '../src' +import { createData } from './utils' + +export default { + title: 'Components/Visualization/LineChart', + component: LineChart, + decorators: [ + (Story: React.ComponentType) => ( + + + + ), + ], +} + +type Story = StoryObj + +export const Basic: Story = { + args: { + data: createData({ + startDate: '2023-01-01', + endDate: '2023-06-30', + categories: ['Revenue'], + growthRate: 1.005, + interval: 7, + }), + height: '300px', + categories: ['Revenue'], + tickFormatter: (value) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(value) + }, + yAxisWidth: 80, + }, + render: (args) => { + return ( + + + + Revenue growth + + + + + + + ) + }, +} + +export const Multiple: Story = { + args: { + data: createData({ + startDate: '2023-12-01', + endDate: '2023-12-31', + categories: ['Backend', 'Frontend'], + startValues: [50, 30], + growthRate: 1.01, + }), + height: '300px', + categories: ['Backend', 'Frontend'], + colors: ['purple', 'cyan'], + }, + render: (args) => { + return ( + + + + Developers + + + + + + + ) + }, +} diff --git a/packages/saas-ui-charts/stories/utils.ts b/packages/saas-ui-charts/stories/utils.ts new file mode 100644 index 000000000..49446c576 --- /dev/null +++ b/packages/saas-ui-charts/stories/utils.ts @@ -0,0 +1,45 @@ +import { format, eachDayOfInterval } from 'date-fns' + +export const createData = ({ + startDate, + endDate, + interval = 1, + growthRate = 1.02, + categories = [], + startValues = [], +}: { + startDate: string + endDate: string + interval?: number + growthRate?: number + categories: string[] + startValues?: number[] +}) => { + const days = eachDayOfInterval({ + start: new Date(startDate), + end: new Date(endDate), + }) + + const values: Record[] = [] + + for (let i = 0; i < days.length; i += interval) { + const data = { + date: format(days[i], 'MMM d'), + } + + for (const category of categories) { + const dailyGrowth = Math.random() * 0.3 + 0.7 + + const value = + (startValues[categories.indexOf(category)] || 2000) * + Math.pow(growthRate, i) * + dailyGrowth + + data[category] = Math.round(value) + } + + values.push(data) + } + + return values +} diff --git a/yarn.lock b/yarn.lock index 0fa686e57..c5c1ca15b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8240,6 +8240,7 @@ __metadata: resolution: "@saas-ui/charts@workspace:packages/saas-ui-charts" dependencies: "@chakra-ui/styled-system": "npm:^2.9.1" + date-fns: "npm:^3.1.0" prop-types: "npm:^15.8.1" recharts: "npm:^2.8.0" tsup: "npm:^6.7.0" @@ -16032,6 +16033,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^3.1.0": + version: 3.1.0 + resolution: "date-fns@npm:3.1.0" + checksum: d542977c57fbb2166a569e235af5e8dde884f9a15fd6778df41a81237dae2a49891b041c8e4b12dae5270f5f00e508907fe7eee07ad24291edad0d75de352006 + languageName: node + linkType: hard + "debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.6.0, debug@npm:^2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9"