Skip to content

Commit

Permalink
feat: introduced stacked bar chart and tree map chart. (#6305)
Browse files Browse the repository at this point in the history
  • Loading branch information
prateekshourya29 authored Jan 3, 2025
1 parent 873e433 commit d6bcd8d
Show file tree
Hide file tree
Showing 10 changed files with 460 additions and 0 deletions.
53 changes: 53 additions & 0 deletions packages/types/src/charts.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export type TStackItem<T extends string> = {
key: T;
fillClassName: string;
textClassName: string;
dotClassName?: string;
showPercentage?: boolean;
};

export type TStackChartData<K extends string, T extends string> = {
[key in K]: string | number;
} & Record<T, any>;

export type TStackedBarChartProps<K extends string, T extends string> = {
data: TStackChartData<K, T>[];
stacks: TStackItem<T>[];
xAxis: {
key: keyof TStackChartData<K, T>;
label: string;
};
yAxis: {
key: keyof TStackChartData<K, T>;
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;
};
1 change: 1 addition & 0 deletions packages/types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export * from "./command-palette";
export * from "./timezone";
export * from "./activity";
export * from "./epics";
export * from "./charts";
63 changes: 63 additions & 0 deletions web/core/components/core/charts/stacked-bar-chart/bar.tsx
Original file line number Diff line number Diff line change
@@ -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 = <K extends string, T extends string>(
data: TStackChartData<K, T>,
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<any>((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 (
<g>
<path
d={`
M${x + RADIUS},${y + height}
L${x + RADIUS},${y}
Q${x},${y} ${x},${y + RADIUS}
L${x},${y + height - RADIUS}
Q${x},${y + height} ${x + RADIUS},${y + height}
L${x + width - RADIUS},${y + height}
Q${x + width},${y + height} ${x + width},${y + height - RADIUS}
L${x + width},${y + RADIUS}
Q${x + width},${y} ${x + width - RADIUS},${y}
L${x + RADIUS},${y}
`}
className={cn("transition-colors duration-200", fill)}
fill="currentColor"
/>
{showPercentage &&
height >= MIN_BAR_HEIGHT_FOR_INTERNAL &&
currentBarPercentage !== undefined &&
!Number.isNaN(currentBarPercentage) && (
<text
x={x + width / 2}
y={textY}
textAnchor="middle"
className={cn("text-xs font-medium", textClassName)}
fill="currentColor"
>
{currentBarPercentage}%
</text>
)}
</g>
);
});
CustomStackBar.displayName = "CustomStackBar";
1 change: 1 addition & 0 deletions web/core/components/core/charts/stacked-bar-chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./root";
130 changes: 130 additions & 0 deletions web/core/components/core/charts/stacked-bar-chart/root.tsx
Original file line number Diff line number Diff line change
@@ -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(
<K extends string, T extends string>({
data,
stacks,
xAxis,
yAxis,
barSize = 40,
className = "w-full h-96",
tickCount = {
x: undefined,
y: 10,
},
showTooltip = true,
}: TStackedBarChartProps<K, T>) => {
// 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) => (
<Bar
key={stack.key}
dataKey={stack.key}
stackId="a"
fill={stack.fillClassName}
shape={(props: any) => (
<CustomStackBar
{...props}
stackKeys={stackKeys}
textClassName={stack.textClassName}
showPercentage={stack.showPercentage}
/>
)}
/>
)),
[stackKeys, stacks]
);

return (
<div className={cn(className)}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
barSize={barSize}
className="recharts-wrapper"
>
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
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}
/>
<YAxis
domain={yAxis.domain}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
label={{
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: LABEL_CLASSNAME,
}}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={yAxis.allowDecimals ?? false}
/>
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
stackKeys={stackKeys}
stackDotClassNames={stackDotClassNames}
/>
)}
/>
)}
{renderBars}
</BarChart>
</ResponsiveContainer>
</div>
);
}
);
StackedBarChart.displayName = "StackedBarChart";
23 changes: 23 additions & 0 deletions web/core/components/core/charts/stacked-bar-chart/tick.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{payload.value}
</text>
</g>
));
CustomXAxisTick.displayName = "CustomXAxisTick";

export const CustomYAxisTick = React.memo<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>
<text dx={-10} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{payload.value}
</text>
</g>
));
CustomYAxisTick.displayName = "CustomYAxisTick";
39 changes: 39 additions & 0 deletions web/core/components/core/charts/stacked-bar-chart/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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 (
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
{label}
</p>
{filteredPayload.map((item: any) => (
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
{stackDotClassNames[item?.dataKey] && (
<div className={cn("size-2 rounded-full", stackDotClassNames[item?.dataKey])} />
)}
<span className="text-custom-text-300">{item?.name}:</span>
<span className="font-medium text-custom-text-200">{item?.value}</span>
</div>
))}
</Card>
);
}
);
CustomTooltip.displayName = "CustomTooltip";
1 change: 1 addition & 0 deletions web/core/components/core/charts/tree-map/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./root";
Loading

0 comments on commit d6bcd8d

Please sign in to comment.