Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
Comment on lines +35 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid using spread syntax inside reduce.

Spreading objects within a .reduce callback leads to O(n^2) time complexity. Consider using mutable approaches (e.g., acc[stack.key] = stack.dotClassName; return acc;) to improve performance when dealing with large data sets.

- () => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}),
+ () => stacks.reduce((acc, stack) => {
+   acc[stack.key] = stack.dotClassName;
+   return acc;
+ }, {}),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
() => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}),
[stacks]
() => stacks.reduce((acc, stack) => {
acc[stack.key] = stack.dotClassName;
return acc;
}, {}),
[stacks]
🧰 Tools
🪛 Biome (1.9.4)

[error] 35-35: Avoid the use of spread (...) syntax on accumulators.

Spread syntax should be avoided on accumulators (like those in .reduce) because it causes a time complexity of O(n^2).
Consider methods such as .splice or .push instead.

(lint/performance/noAccumulatingSpread)

);

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
Loading