From d6bcd8dd150aa3e36b9d2976735fa2f01e223cf0 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 3 Jan 2025 14:24:14 +0530 Subject: [PATCH] feat: introduced stacked bar chart and tree map chart. (#6305) --- packages/types/src/charts.d.ts | 53 +++++++ packages/types/src/index.d.ts | 1 + .../core/charts/stacked-bar-chart/bar.tsx | 63 +++++++++ .../core/charts/stacked-bar-chart/index.ts | 1 + .../core/charts/stacked-bar-chart/root.tsx | 130 ++++++++++++++++++ .../core/charts/stacked-bar-chart/tick.tsx | 23 ++++ .../core/charts/stacked-bar-chart/tooltip.tsx | 39 ++++++ .../components/core/charts/tree-map/index.ts | 1 + .../core/charts/tree-map/map-content.tsx | 119 ++++++++++++++++ .../components/core/charts/tree-map/root.tsx | 30 ++++ 10 files changed, 460 insertions(+) create mode 100644 packages/types/src/charts.d.ts create mode 100644 web/core/components/core/charts/stacked-bar-chart/bar.tsx create mode 100644 web/core/components/core/charts/stacked-bar-chart/index.ts create mode 100644 web/core/components/core/charts/stacked-bar-chart/root.tsx create mode 100644 web/core/components/core/charts/stacked-bar-chart/tick.tsx create mode 100644 web/core/components/core/charts/stacked-bar-chart/tooltip.tsx create mode 100644 web/core/components/core/charts/tree-map/index.ts create mode 100644 web/core/components/core/charts/tree-map/map-content.tsx create mode 100644 web/core/components/core/charts/tree-map/root.tsx diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts.d.ts new file mode 100644 index 0000000000..b4acdbd620 --- /dev/null +++ b/packages/types/src/charts.d.ts @@ -0,0 +1,53 @@ +export type TStackItem = { + key: T; + fillClassName: string; + textClassName: string; + dotClassName?: string; + showPercentage?: boolean; +}; + +export type TStackChartData = { + [key in K]: string | number; +} & Record; + +export type TStackedBarChartProps = { + data: TStackChartData[]; + stacks: TStackItem[]; + xAxis: { + key: keyof TStackChartData; + label: string; + }; + yAxis: { + key: keyof TStackChartData; + label: string; + domain?: [number, number]; + allowDecimals?: boolean; + }; + barSize?: number; + className?: string; + tickCount?: { + x?: number; + y?: number; + }; + showTooltip?: boolean; +}; + +export type TreeMapItem = { + name: string; + value: number; + textClassName?: string; + icon?: React.ReactElement; +} & ( + | { + fillColor: string; + } + | { + fillClassName: string; + } + ); + +export type TreeMapChartProps = { + data: TreeMapItem[]; + className?: string; + isAnimationActive?: boolean; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index af1e3ff485..cd55c1873f 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -37,3 +37,4 @@ export * from "./command-palette"; export * from "./timezone"; export * from "./activity"; export * from "./epics"; +export * from "./charts"; diff --git a/web/core/components/core/charts/stacked-bar-chart/bar.tsx b/web/core/components/core/charts/stacked-bar-chart/bar.tsx new file mode 100644 index 0000000000..96fd9b3cea --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/bar.tsx @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +// plane imports +import { TStackChartData } from "@plane/types"; +import { cn } from "@plane/utils"; + +// Helper to calculate percentage +const calculatePercentage = ( + data: TStackChartData, + stackKeys: T[], + currentKey: T +): number => { + const total = stackKeys.reduce((sum, key) => sum + data[key], 0); + return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100); +}; + +export const CustomStackBar = React.memo((props: any) => { + const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props; + // Calculate text position + const MIN_BAR_HEIGHT_FOR_INTERNAL = 14; // Minimum height needed to show text inside + const TEXT_PADDING = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL - height / 2)); + const textY = y + height - TEXT_PADDING; // Position inside bar if tall enough + // derived values + const RADIUS = 2; + const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey); + + if (!height) return null; + return ( + + + {showPercentage && + height >= MIN_BAR_HEIGHT_FOR_INTERNAL && + currentBarPercentage !== undefined && + !Number.isNaN(currentBarPercentage) && ( + + {currentBarPercentage}% + + )} + + ); +}); +CustomStackBar.displayName = "CustomStackBar"; diff --git a/web/core/components/core/charts/stacked-bar-chart/index.ts b/web/core/components/core/charts/stacked-bar-chart/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/core/charts/stacked-bar-chart/root.tsx b/web/core/components/core/charts/stacked-bar-chart/root.tsx new file mode 100644 index 0000000000..2fd8ccfc6c --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/root.tsx @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import React from "react"; +import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts"; +// plane imports +import { TStackedBarChartProps } from "@plane/types"; +import { cn } from "@plane/utils"; +// local components +import { CustomStackBar } from "./bar"; +import { CustomXAxisTick, CustomYAxisTick } from "./tick"; +import { CustomTooltip } from "./tooltip"; + +// Common classnames +const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; +const AXIS_LINE_CLASSNAME = "text-custom-text-400/70"; + +export const StackedBarChart = React.memo( + ({ + data, + stacks, + xAxis, + yAxis, + barSize = 40, + className = "w-full h-96", + tickCount = { + x: undefined, + y: 10, + }, + showTooltip = true, + }: TStackedBarChartProps) => { + // derived values + const stackKeys = React.useMemo(() => stacks.map((stack) => stack.key), [stacks]); + const stackDotClassNames = React.useMemo( + () => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}), + [stacks] + ); + + const renderBars = React.useMemo( + () => + stacks.map((stack) => ( + ( + + )} + /> + )), + [stackKeys, stacks] + ); + + return ( +
+ + + } + tickLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + axisLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + label={{ + value: xAxis.label, + dy: 28, + className: LABEL_CLASSNAME, + }} + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={yAxis.allowDecimals ?? false} + /> + {showTooltip && ( + ( + + )} + /> + )} + {renderBars} + + +
+ ); + } +); +StackedBarChart.displayName = "StackedBarChart"; diff --git a/web/core/components/core/charts/stacked-bar-chart/tick.tsx b/web/core/components/core/charts/stacked-bar-chart/tick.tsx new file mode 100644 index 0000000000..c631d7d6e2 --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/tick.tsx @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; + +// Common classnames +const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize"; + +export const CustomXAxisTick = React.memo(({ x, y, payload }: any) => ( + + + {payload.value} + + +)); +CustomXAxisTick.displayName = "CustomXAxisTick"; + +export const CustomYAxisTick = React.memo(({ x, y, payload }: any) => ( + + + {payload.value} + + +)); +CustomYAxisTick.displayName = "CustomYAxisTick"; diff --git a/web/core/components/core/charts/stacked-bar-chart/tooltip.tsx b/web/core/components/core/charts/stacked-bar-chart/tooltip.tsx new file mode 100644 index 0000000000..32226db3f1 --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/tooltip.tsx @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +// plane imports +import { Card, ECardSpacing } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type TStackedBarChartProps = { + active: boolean | undefined; + label: string | undefined; + payload: any[] | undefined; + stackKeys: string[]; + stackDotClassNames: Record; +}; + +export const CustomTooltip = React.memo( + ({ active, label, payload, stackKeys, stackDotClassNames }: TStackedBarChartProps) => { + // derived values + const filteredPayload = payload?.filter((item: any) => item.dataKey && stackKeys.includes(item.dataKey)); + + if (!active || !filteredPayload || !filteredPayload.length) return null; + return ( + +

+ {label} +

+ {filteredPayload.map((item: any) => ( +
+ {stackDotClassNames[item?.dataKey] && ( +
+ )} + {item?.name}: + {item?.value} +
+ ))} + + ); + } +); +CustomTooltip.displayName = "CustomTooltip"; diff --git a/web/core/components/core/charts/tree-map/index.ts b/web/core/components/core/charts/tree-map/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/core/components/core/charts/tree-map/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/core/charts/tree-map/map-content.tsx b/web/core/components/core/charts/tree-map/map-content.tsx new file mode 100644 index 0000000000..e99988a70a --- /dev/null +++ b/web/core/components/core/charts/tree-map/map-content.tsx @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +// plane imports +import { Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; +// constants +const AVG_WIDTH_RATIO = 0.7; + +const isTruncateRequired = (text: string, maxWidth: number, fontSize: number) => { + // Approximate width per character (this is an estimation) + const avgCharWidth = fontSize * AVG_WIDTH_RATIO; + const maxChars = Math.floor(maxWidth / avgCharWidth); + + return text.length > maxChars; +}; + +const truncateText = (text: string, maxWidth: number, fontSize: number) => { + // Approximate width per character (this is an estimation) + const avgCharWidth = fontSize * AVG_WIDTH_RATIO; + const maxChars = Math.floor(maxWidth / avgCharWidth); + if (text.length > maxChars) { + return text.slice(0, maxChars - 2) + "..."; + } + return text; +}; + +export const CustomTreeMapContent = ({ + x, + y, + width, + height, + name, + value, + fillColor, + fillClassName, + textClassName, + icon, +}: any) => { + const RADIUS = 10; + const PADDING = 5; + // Apply padding to dimensions + const pX = x + PADDING; + const pY = y + PADDING; + const pWidth = width - PADDING * 2; + const pHeight = height - PADDING * 2; + // Text padding from the left edge + const TEXT_PADDING_LEFT = 12; + const TEXT_PADDING_RIGHT = 12; + // Icon size and spacing + const ICON_SIZE = 16; + const ICON_TEXT_GAP = 6; + // Available width for the text + const iconSpace = icon ? ICON_SIZE + ICON_TEXT_GAP : 0; + const availableWidth = pWidth - TEXT_PADDING_LEFT - TEXT_PADDING_RIGHT - iconSpace; + // Truncate text based on available width + // 12.6px for text-sm + const isTextTruncated = typeof name === "string" ? isTruncateRequired(name, availableWidth, 12.6) : name; + const truncatedName = typeof name === "string" ? truncateText(name, availableWidth, 12.6) : name; + + if (!name) return; // To remove the total count + return ( + + + + + {icon && ( + + {React.cloneElement(icon, { + className: cn("size-4", icon?.props?.className), + "aria-hidden": true, + })} + + )} + + {truncatedName} + + + + + {value?.toLocaleString()} + + + ); +}; diff --git a/web/core/components/core/charts/tree-map/root.tsx b/web/core/components/core/charts/tree-map/root.tsx new file mode 100644 index 0000000000..5e0ecf5e6c --- /dev/null +++ b/web/core/components/core/charts/tree-map/root.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Treemap, ResponsiveContainer } from "recharts"; +// plane imports +import { TreeMapChartProps } from "@plane/types"; +import { cn } from "@plane/utils"; +// local imports +import { CustomTreeMapContent } from "./map-content"; + +export const TreeMapChart = React.memo((props: TreeMapChartProps) => { + const { data, className = "w-full h-96", isAnimationActive = false } = props; + return ( +
+ + } + animationEasing="ease-out" + isUpdateAnimationActive={isAnimationActive} + animationBegin={100} + animationDuration={500} + /> + +
+ ); +}); +TreeMapChart.displayName = "TreeMapChart";